From 7e9204946edcb0a178df3377ab391bf21d303e7b Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:32:44 +0530 Subject: [PATCH 01/49] Bound filesystem reads, scans, and watch registrations --- .../boss/ipc/v1/services/filesystem.proto | 4 +- .../boss-service-filesystem/build.gradle.kts | 1 + .../filesystem/BoundedDirectoryScan.kt | 103 ++++++++ .../service/filesystem/FileSystemLimits.kt | 20 ++ .../filesystem/FileSystemServiceImpl.kt | 167 +++---------- .../service/filesystem/FileWatchRegistry.kt | 163 +++++++++++++ .../service/filesystem/RegularFileReader.kt | 167 +++++++++++++ .../filesystem/FileSystemLimitsTest.kt | 228 ++++++++++++++++++ .../plugin/ipc/FileSystemDataProviderProxy.kt | 46 +++- .../plugin/ipc/FileSystemReadPagingTest.kt | 86 +++++++ 10 files changed, 842 insertions(+), 143 deletions(-) create mode 100644 modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/BoundedDirectoryScan.kt create mode 100644 modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemLimits.kt create mode 100644 modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileWatchRegistry.kt create mode 100644 modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt create mode 100644 modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt create mode 100644 plugin-platform/plugin-api-ipc/src/desktopTest/kotlin/ai/rever/boss/plugin/ipc/FileSystemReadPagingTest.kt diff --git a/modules/boss-ipc/src/main/proto/boss/ipc/v1/services/filesystem.proto b/modules/boss-ipc/src/main/proto/boss/ipc/v1/services/filesystem.proto index 38d31c38c2..e65b377c4a 100644 --- a/modules/boss-ipc/src/main/proto/boss/ipc/v1/services/filesystem.proto +++ b/modules/boss-ipc/src/main/proto/boss/ipc/v1/services/filesystem.proto @@ -51,7 +51,7 @@ message ScanDirectoryRequest { bool recursive = 2; // If true, scan subdirectories too bool include_hidden = 3; // If true, include hidden files/dirs (starting with '.') repeated string extensions = 4; // Filter by extension (e.g., "kt", "ts"). Empty = all. - int32 max_depth = 5; // Max recursion depth (0 = no limit) + int32 max_depth = 5; // Requested depth; server caps depth at 64 and scanned entries at 10,000 } message ScanDirectoryResponse { @@ -64,7 +64,7 @@ message ScanDirectoryResponse { message ReadFileRequest { string path = 1; int64 offset_bytes = 2; // 0 = start - int64 max_bytes = 3; // 0 = read all + int64 max_bytes = 3; // Explicit page size, capped at 1 MiB; 0 = read all or refuse an oversized file } message ReadFileResponse { diff --git a/modules/boss-service-filesystem/build.gradle.kts b/modules/boss-service-filesystem/build.gradle.kts index 648d6885de..5be0acb306 100644 --- a/modules/boss-service-filesystem/build.gradle.kts +++ b/modules/boss-service-filesystem/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(project(":boss-ipc")) implementation(libs.kotlinx.coroutines.core) implementation(libs.slf4j.api) + implementation(libs.jna) runtimeOnly(libs.slf4j.simple) testImplementation(libs.kotlin.test.junit) diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/BoundedDirectoryScan.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/BoundedDirectoryScan.kt new file mode 100644 index 0000000000..537ac30ffb --- /dev/null +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/BoundedDirectoryScan.kt @@ -0,0 +1,103 @@ +package ai.rever.boss.service.filesystem + +import ai.rever.boss.ipc.proto.services.FileEntry +import ai.rever.boss.ipc.proto.services.ScanDirectoryRequest +import kotlinx.coroutines.ensureActive +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes +import kotlin.coroutines.CoroutineContext + +internal class BoundedDirectoryScan( + private val request: ScanDirectoryRequest, + private val context: CoroutineContext, +) { + private val requestedDepth = if (!request.recursive) 1 else request.maxDepth.takeIf { it > 0 } ?: Int.MAX_VALUE + private val depth = requestedDepth.coerceAtMost(FileSystemLimits.SCAN_DEPTH) + private val extensions = request.extensionsList.toSet() + private val entries = mutableListOf() + private var visited = 0 + private var responseBytes = 0 + + fun scan(root: Path): List { + val target = root.toRealPath() + Files.walkFileTree( + target, + emptySet(), + depth, + object : SimpleFileVisitor() { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes, + ) = visit(target, root, dir, attrs) + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes, + ) = visit(target, root, file, attrs) + }, + ) + return entries + } + + private fun visit( + root: Path, + visibleRoot: Path, + path: Path, + attributes: BasicFileAttributes, + ): FileVisitResult { + context.ensureActive() + enforceFileSystemLimit(++visited <= FileSystemLimits.SCAN_ENTRIES, "Directory scan entry limit reached") + val name = path.fileName?.toString().orEmpty() + return when { + path == root -> { + FileVisitResult.CONTINUE + } + + !request.includeHidden && name.startsWith('.') -> { + FileVisitResult.SKIP_SUBTREE + } + + else -> { + checkDepth(root, path, attributes) + if (attributes.isDirectory || extensions.isEmpty() || name.substringAfterLast('.', "") in extensions) { + add(visibleRoot.resolve(root.relativize(path)), attributes) + } + FileVisitResult.CONTINUE + } + } + } + + private fun checkDepth( + root: Path, + path: Path, + attributes: BasicFileAttributes, + ) { + if (attributes.isDirectory && requestedDepth > depth && root.relativize(path).nameCount == depth) { + Files.newDirectoryStream(path).use { + enforceFileSystemLimit(!it.iterator().hasNext(), "Directory scan depth limit reached") + } + } + } + + private fun add( + path: Path, + attributes: BasicFileAttributes, + ) { + val entry = + FileEntry + .newBuilder() + .setPath(path.toString()) + .setName(path.fileName.toString()) + .setIsDirectory(attributes.isDirectory) + .setSizeBytes(if (attributes.isDirectory) 0L else attributes.size()) + .setModifiedAt(attributes.lastModifiedTime().toMillis()) + .setIsHidden(path.fileName.toString().startsWith('.')) + .build() + responseBytes += entry.serializedSize + 8 + enforceFileSystemLimit(responseBytes <= FileSystemLimits.SCAN_BYTES, "Directory scan response limit reached") + entries.add(entry) + } +} diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemLimits.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemLimits.kt new file mode 100644 index 0000000000..14c0be2d10 --- /dev/null +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemLimits.kt @@ -0,0 +1,20 @@ +package ai.rever.boss.service.filesystem + +import io.grpc.Status + +/** Bounded RPC pages fit below gRPC's default message limit; callers can page large file reads explicitly. */ +internal object FileSystemLimits { + const val READ_BYTES = 1_048_576 + const val SCAN_ENTRIES = 10_000 + const val SCAN_DEPTH = 64 + const val SCAN_BYTES = 2_097_152 +} + +internal fun fileSystemLimit(message: String) = Status.RESOURCE_EXHAUSTED.withDescription(message).asRuntimeException() + +internal fun enforceFileSystemLimit( + condition: Boolean, + message: String, +) { + if (!condition) throw fileSystemLimit(message) +} diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt index 7dfb610480..6185601e6d 100644 --- a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt @@ -5,11 +5,11 @@ import ai.rever.boss.ipc.proto.services.* import com.google.protobuf.ByteString import io.grpc.Status import io.grpc.StatusException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory import java.io.File @@ -17,14 +17,10 @@ import java.io.IOException import java.nio.file.AccessDeniedException import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.FileAlreadyExistsException -import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.nio.file.StandardCopyOption -import java.nio.file.StandardWatchEventKinds -import java.nio.file.WatchEvent -import java.util.concurrent.TimeUnit /** * gRPC implementation of FileSystemService. @@ -60,32 +56,7 @@ class FileSystemServiceImpl : FileSystemServiceGrpcKt.FileSystemServiceCoroutine .build() } - val maxDepth = if (request.maxDepth > 0) request.maxDepth else Int.MAX_VALUE - val sequence: Sequence = - if (request.recursive) { - dir.walkTopDown().maxDepth(maxDepth) - } else { - dir.listFiles()?.asSequence() ?: emptySequence() - } - - val filterExtensions = request.extensionsList.toSet() - - val entries = - sequence - .filter { it != dir } - .filter { request.includeHidden || !it.name.startsWith(".") } - .filter { it.isDirectory || filterExtensions.isEmpty() || it.extension in filterExtensions } - .map { f -> - FileEntry - .newBuilder() - .setPath(f.absolutePath) - .setName(f.name) - .setIsDirectory(f.isDirectory) - .setSizeBytes(if (f.isDirectory) 0L else f.length()) - .setModifiedAt(f.lastModified()) - .setIsHidden(f.name.startsWith(".")) - .build() - }.toList() + val entries = BoundedDirectoryScan(request, currentCoroutineContext()).scan(dir.toPath().toAbsolutePath()) ScanDirectoryResponse .newBuilder() @@ -105,23 +76,34 @@ class FileSystemServiceImpl : FileSystemServiceGrpcKt.FileSystemServiceCoroutine .build() } return@withContext try { - val totalSize = file.length() - val bytes = file.readBytes() - val offsetBytes = request.offsetBytes.coerceAtLeast(0L).toInt() - val slice = if (offsetBytes > 0 && offsetBytes < bytes.size) bytes.drop(offsetBytes).toByteArray() else bytes - val maxBytes = request.maxBytes - val (content, truncated) = - if (maxBytes > 0 && slice.size > maxBytes) { - slice.take(maxBytes.toInt()).toByteArray() to true - } else { - slice to false + require(request.offsetBytes >= 0 && request.maxBytes >= 0) { + "Read offsets and limits must be nonnegative" + } + val maximum = + request.maxBytes + .takeIf { it > 0 } + ?.coerceAtMost(FileSystemLimits.READ_BYTES.toLong()) + ?.toInt() ?: FileSystemLimits.READ_BYTES + openRegularFile(file.toPath()).use { reader -> + val totalSize = reader.size + val bytes = reader.readPage(request.offsetBytes, maximum + 1) + currentCoroutineContext().ensureActive() + val truncated = bytes.size > maximum + // Legacy 'all' readers may ignore truncated. Refuse instead of returning partial text. + if (truncated && request.maxBytes == 0L) { + throw fileSystemLimit( + "File exceeds one response; read it using explicit byte limits and offsets", + ) } - ReadFileResponse - .newBuilder() - .setContent(ByteString.copyFrom(content)) - .setTotalSizeBytes(totalSize) - .setTruncated(truncated) - .build() + ReadFileResponse + .newBuilder() + .setContent(ByteString.copyFrom(bytes, 0, bytes.size.coerceAtMost(maximum))) + .setTotalSizeBytes(totalSize) + .setTruncated(truncated) + .build() + } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { ReadFileResponse .newBuilder() @@ -266,89 +248,10 @@ class FileSystemServiceImpl : FileSystemServiceGrpcKt.FileSystemServiceCoroutine cause: Throwable?, ) = StatusException(code.withDescription(description).withCause(cause)) - override fun watchFileChanges(request: WatchFileChangesRequest): Flow = - flow { - logger.info("watchFileChanges: path={}, recursive={}", request.path, request.recursive) - validatePath(request.path) - val root = Paths.get(request.path) - if (!Files.exists(root)) { - logger.warn("watchFileChanges: path not found: {}", request.path) - return@flow - } + private val watches = FileWatchRegistry() - val kinds = - arrayOf( - StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_MODIFY, - StandardWatchEventKinds.ENTRY_DELETE, - ) - - val watchService = FileSystems.getDefault().newWatchService() - try { - // Register root (and subdirs if recursive) - root.register(watchService, *kinds) - if (request.recursive && Files.isDirectory(root)) { - Files - .walk(root) - .filter { Files.isDirectory(it) && it != root } - .forEach { dir -> - try { - dir.register(watchService, *kinds) - } catch (_: Exception) { - } - } - } - - while (currentCoroutineContext().isActive) { - val key = - withContext(Dispatchers.IO) { - watchService.poll(500, TimeUnit.MILLISECONDS) - } ?: continue - - for (event in key.pollEvents()) { - if (event.kind() == StandardWatchEventKinds.OVERFLOW) continue - - @Suppress("UNCHECKED_CAST") - val ev = event as WatchEvent - val dir = key.watchable() as Path - val filePath = dir.resolve(ev.context()) - - val changeType = - when (event.kind()) { - StandardWatchEventKinds.ENTRY_CREATE -> FileChangeType.FILE_CHANGE_TYPE_CREATED - StandardWatchEventKinds.ENTRY_MODIFY -> FileChangeType.FILE_CHANGE_TYPE_MODIFIED - StandardWatchEventKinds.ENTRY_DELETE -> FileChangeType.FILE_CHANGE_TYPE_DELETED - else -> FileChangeType.FILE_CHANGE_TYPE_UNSPECIFIED - } - - // Register newly created directories for recursive watching - if (request.recursive && - event.kind() == StandardWatchEventKinds.ENTRY_CREATE && - Files.isDirectory(filePath) - ) { - try { - filePath.register(watchService, *kinds) - } catch (_: Exception) { - } - } - - emit( - FileChangeEvent - .newBuilder() - .setPath(filePath.toAbsolutePath().toString()) - .setChangeType(changeType) - .setTimestamp(System.currentTimeMillis()) - .build(), - ) - } - - if (!key.reset()) { - logger.info("watchFileChanges: watch key invalid, stopping: {}", request.path) - break - } - } - } finally { - watchService.close() - } - } + override fun watchFileChanges(request: WatchFileChangesRequest): Flow { + validatePath(request.path) + return watches.watch(request) + } } diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileWatchRegistry.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileWatchRegistry.kt new file mode 100644 index 0000000000..e1f40ba7c7 --- /dev/null +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileWatchRegistry.kt @@ -0,0 +1,163 @@ +package ai.rever.boss.service.filesystem + +import ai.rever.boss.ipc.proto.services.FileChangeEvent +import ai.rever.boss.ipc.proto.services.FileChangeType +import ai.rever.boss.ipc.proto.services.WatchFileChangesRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.io.IOException +import java.nio.file.FileSystems +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS +import java.nio.file.NoSuchFileException +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE +import java.nio.file.StandardWatchEventKinds.ENTRY_DELETE +import java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY +import java.nio.file.StandardWatchEventKinds.OVERFLOW +import java.nio.file.WatchKey +import java.nio.file.WatchService +import java.nio.file.attribute.BasicFileAttributes +import java.util.concurrent.Semaphore +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext + +/** Every collector owns its registrations until cancellation, failure, or deletion of its root. */ +internal class FileWatchRegistry { + private val slots = Semaphore(32) + + fun watch(request: WatchFileChangesRequest): Flow = + flow { + if (!slots.tryAcquire()) throw fileSystemLimit("Too many active file watches") + try { + val root = Path.of(request.path).toAbsolutePath().normalize() + require(Files.isDirectory(root, NOFOLLOW_LINKS)) { "Watch root must be a directory" } + FileSystems.getDefault().newWatchService().use { service -> + val registrations = Registrations(service, root, currentCoroutineContext()) + registrations.add(root, request.recursive) + var active = true + while (active) { + currentCoroutineContext().ensureActive() + val key = service.poll(500, TimeUnit.MILLISECONDS) + if (key != null) { + emitChanges(key, request.recursive, registrations) + if (!key.reset()) { + registrations.remove(key) + active = key.watchable() != root + } + } + } + } + } finally { + slots.release() + } + }.flowOn(Dispatchers.IO) + + private suspend fun FlowCollector.emitChanges( + key: WatchKey, + recursive: Boolean, + registrations: Registrations, + ) { + val directory = key.watchable() as Path + for (event in key.pollEvents()) { + currentCoroutineContext().ensureActive() + if (event.kind() == OVERFLOW) throw fileSystemLimit("File watch overflow; rescan and reconnect") + val relative = event.context() as? Path ?: continue + val path = directory.resolve(relative) + if (recursive && event.kind() == ENTRY_CREATE && Files.isDirectory(path, NOFOLLOW_LINKS)) { + registrations.add(path, true) + } + val type = + when (event.kind()) { + ENTRY_CREATE -> FileChangeType.FILE_CHANGE_TYPE_CREATED + ENTRY_MODIFY -> FileChangeType.FILE_CHANGE_TYPE_MODIFIED + ENTRY_DELETE -> FileChangeType.FILE_CHANGE_TYPE_DELETED + else -> FileChangeType.FILE_CHANGE_TYPE_UNSPECIFIED + } + emit( + FileChangeEvent + .newBuilder() + .setPath(path.toString()) + .setChangeType(type) + .setTimestamp(System.currentTimeMillis()) + .build(), + ) + } + } + + private class Registrations( + private val service: WatchService, + private val root: Path, + private val context: CoroutineContext, + ) { + private val keys = mutableSetOf() + + fun remove(key: WatchKey) { + keys.remove(key) + } + + fun add( + path: Path, + recursive: Boolean, + ) { + var visited = 0 + Files.walkFileTree( + path, + emptySet(), + if (recursive) FileSystemLimits.SCAN_DEPTH + 1 else 0, + object : SimpleFileVisitor() { + private fun visit( + entry: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + context.ensureActive() + enforceFileSystemLimit( + ++visited <= FileSystemLimits.SCAN_ENTRIES, + "File watch scan limit reached", + ) + if (attrs.isDirectory) { + enforceFileSystemLimit( + root.relativize(entry).nameCount <= FileSystemLimits.SCAN_DEPTH, + "File watch depth limit reached", + ) + keys.removeAll { !it.isValid } + enforceFileSystemLimit(keys.size < 1024, "File watch directory limit reached") + try { + keys.add(entry.register(service, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE)) + } catch (e: NoSuchFileException) { + if (entry == root) throw e + } + } + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path, + exc: IOException, + ): FileVisitResult { + context.ensureActive() + if (exc is NoSuchFileException && file != root) return FileVisitResult.CONTINUE + throw exc + } + + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes, + ) = visit(dir, attrs) + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes, + ) = visit(file, attrs) + }, + ) + } + } +} diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt new file mode 100644 index 0000000000..427046fb5a --- /dev/null +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt @@ -0,0 +1,167 @@ +package ai.rever.boss.service.filesystem + +import com.sun.jna.Function +import com.sun.jna.Library +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.NativeLibrary +import com.sun.jna.Platform +import com.sun.jna.Pointer +import com.sun.jna.WString +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes + +/** Checks the opened object, never a pathname checked before a potentially blocking open. */ +internal interface RegularFileReader : AutoCloseable { + val size: Long + + fun read( + offset: Long, + destination: ByteArray, + count: Int, + ): Int +} + +internal fun openRegularFile(path: Path): RegularFileReader { + check(Native.POINTER_SIZE == 8) { "Regular-file reads require a supported 64-bit platform" } + return when { + Platform.isWindows() -> { + WindowsRegularFile(path) + } + + Platform.isMac() || (Platform.isLinux() && Platform.ARCH in setOf("x86-64", "aarch64")) -> { + PosixRegularFile(path) + } + + else -> { + throw IOException("Regular-file reads are unavailable on this platform") + } + } +} + +internal suspend fun RegularFileReader.readPage( + offset: Long, + maximum: Int, +): ByteArray { + if (offset >= size) return byteArrayOf() + val buffer = ByteArray(minOf(65_536, maximum)) + val output = ByteArrayOutputStream() + val limit = minOf(maximum.toLong(), Long.MAX_VALUE - offset).toInt() + while (output.size() < limit) { + currentCoroutineContext().ensureActive() + val position = offset + output.size() + val count = read(position, buffer, minOf(buffer.size, limit - output.size())) + if (count == 0) break + output.write(buffer, 0, count) + } + return output.toByteArray() +} + +private class PosixRegularFile( + path: Path, +) : RegularFileReader { + private val descriptor: Int + override val size: Long + + init { + // O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC. Values from Darwin fcntl.h + // and Linux asm-generic/fcntl.h (only the supported 64-bit ABIs above). + val flags = if (Platform.isMac()) 0x0004 or 0x0100 or 0x01000000 else 0x0800 or 0x20000 or 0x80000 + descriptor = libc.getFunction("open").invokeInt(arrayOf(path.toString(), flags)) + if (descriptor < 0) throw nativeReadError("Open") + var initialized = false + try { + // /dev/fd resolves this process's still-open descriptor on Darwin and Linux. + // Failure to inspect it is fatal; never fall back to checking the original path. + val attributes = Files.readAttributes(Path.of("/dev/fd/$descriptor"), BasicFileAttributes::class.java) + require(attributes.isRegularFile) { "Only regular files can be read" } + size = attributes.size() + initialized = true + } finally { + if (!initialized) close() + } + } + + override fun read( + offset: Long, + destination: ByteArray, + count: Int, + ): Int { + val result = libc.getFunction("pread").invokeLong(arrayOf(descriptor, destination, count.toLong(), offset)) + if (result < 0) throw nativeReadError("Read") + return result.toInt() + } + + override fun close() { + libc.getFunction("close").invokeInt(arrayOf(descriptor)) + } + + companion object { + private val libc = + NativeLibrary.getInstance(Platform.C_LIBRARY_NAME, mapOf(Library.OPTION_STRING_ENCODING to "UTF-8")) + } +} + +private class WindowsRegularFile( + path: Path, +) : RegularFileReader { + private val handle: Pointer + override val size: Long + + init { + // OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, non-inheritable handle, shared read/write/delete. + val opened = + kernel.getFunction("CreateFileW", Function.ALT_CONVENTION).invokePointer( + arrayOf(WString(path.toAbsolutePath().toString()), 0x80000000.toInt(), 7, null, 3, 0x00200000, null), + ) + if (opened == null || Pointer.nativeValue(opened) == -1L) throw nativeReadError("Open") + handle = opened + var initialized = false + try { + require(kernel.getFunction("GetFileType").invokeInt(arrayOf(handle)) == 1) { "Only disk files can be read" } + // BY_HANDLE_FILE_INFORMATION has thirteen DWORDs, including three FILETIMEs. + Memory(52).use { info -> + if (kernel.getFunction("GetFileInformationByHandle").invokeInt(arrayOf(handle, info)) == 0) { + throw nativeReadError("Inspect") + } + require(info.getInt(0) and (0x10 or 0x400) == 0) { "Directories and reparse points cannot be read" } + size = (info.getInt(32).toLong() shl 32) or (info.getInt(36).toLong() and 0xffffffffL) + require(size >= 0) { "File exceeds supported size" } + initialized = true + } + } finally { + if (!initialized) close() + } + } + + override fun read( + offset: Long, + destination: ByteArray, + count: Int, + ): Int { + if (kernel.getFunction("SetFilePointerEx").invokeInt(arrayOf(handle, offset, null, 0)) == 0) { + throw nativeReadError("Seek") + } + Memory(4).use { bytesRead -> + if (kernel.getFunction("ReadFile").invokeInt(arrayOf(handle, destination, count, bytesRead, null)) == 0) { + throw nativeReadError("Read") + } + return bytesRead.getInt(0) + } + } + + override fun close() { + kernel.getFunction("CloseHandle").invokeInt(arrayOf(handle)) + } + + companion object { + private val kernel = NativeLibrary.getInstance("kernel32") + } +} + +private fun nativeReadError(operation: String) = IOException("$operation failed (OS error ${Native.getLastError()})") diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt new file mode 100644 index 0000000000..f3c0e783c7 --- /dev/null +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt @@ -0,0 +1,228 @@ +package ai.rever.boss.service.filesystem + +import ai.rever.boss.ipc.proto.services.FileSystemServiceGrpcKt +import ai.rever.boss.ipc.proto.services.ReadFileRequest +import ai.rever.boss.ipc.proto.services.ScanDirectoryRequest +import ai.rever.boss.ipc.proto.services.WatchFileChangesRequest +import com.sun.jna.Platform +import io.grpc.ManagedChannelBuilder +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.io.RandomAccessFile +import java.nio.file.Files +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FileSystemLimitsTest { + private val root = Files.createTempDirectory("filesystem-limits-") + private val server = + ServerBuilder + .forPort(0) + .addService(FileSystemServiceImpl()) + .build() + .start() + private val channel = ManagedChannelBuilder.forAddress("127.0.0.1", server.port).usePlaintext().build() + private val stub = FileSystemServiceGrpcKt.FileSystemServiceCoroutineStub(channel) + + @AfterTest + fun cleanup() { + channel.shutdownNow() + server.shutdownNow() + root.toFile().deleteRecursively() + } + + @Test + fun `native open rejects a FIFO without waiting for a writer`() { + if (Platform.isWindows()) return + val fifo = root.resolve("fifo") + assertEquals(0, ProcessBuilder("mkfifo", fifo.toString()).start().waitFor()) + val worker = Executors.newSingleThreadExecutor { task -> Thread(task).apply { isDaemon = true } } + try { + val rejected = + worker.submit { + try { + openRegularFile(fifo).use { false } + } catch (_: IllegalArgumentException) { + true + } + } + assertTrue(rejected.get(2, TimeUnit.SECONDS)) + } finally { + worker.shutdownNow() + } + } + + @Test + fun `scan follows the requested root alias while leaving descendant links untraversed`() = + runBlocking { + if (Platform.isWindows()) return@runBlocking + val real = Files.createDirectory(root.resolve("real")) + Files.writeString(real.resolve("content"), "ok") + Files.createSymbolicLink(real.resolve("loop"), real) + val alias = Files.createSymbolicLink(root.resolve("alias"), real) + val result = + stub.scanDirectory( + ScanDirectoryRequest + .newBuilder() + .setPath(alias.toString()) + .setRecursive(true) + .build(), + ) + assertEquals( + setOf(alias.resolve("content").toString(), alias.resolve("loop").toString()), + result.entriesList.map { it.path }.toSet(), + ) + } + + @Test + fun `recursive watch survives disappearing descendants and continues observing the root`() = + runBlocking { + withTimeout(15_000) { + val sentinel = root.resolve("sentinel") + val received = + async { + stub + .watchFileChanges( + WatchFileChangesRequest + .newBuilder() + .setPath(root.toString()) + .setRecursive(true) + .build(), + ).first { it.path == sentinel.toString() } + } + repeat(100) { index -> + val transient = Files.createDirectory(root.resolve("transient-$index")) + Files.createDirectory(transient.resolve("child")) + transient.toFile().deleteRecursively() + delay(5) + } + while (!received.isCompleted) { + Files.writeString(sentinel, "still watching") + delay(50) + } + assertEquals(sentinel.toString(), received.await().path) + } + } + + @Test + fun `sparse files support long offsets and refuse unbounded legacy reads`() = + runBlocking { + val file = root.resolve("large") + val offset = Int.MAX_VALUE.toLong() + 32 + RandomAccessFile(file.toFile(), "rw").use { + it.seek(offset) + it.write("tail".toByteArray()) + } + val tail = + stub.readFile( + ReadFileRequest + .newBuilder() + .setPath(file.toString()) + .setOffsetBytes(offset) + .setMaxBytes(16) + .build(), + ) + assertEquals("tail", tail.content.toStringUtf8()) + assertFalse(tail.truncated) + val beyond = + stub.readFile( + ReadFileRequest + .newBuilder() + .setPath(file.toString()) + .setOffsetBytes(Long.MAX_VALUE) + .build(), + ) + assertTrue(beyond.content.isEmpty) + assertTrue(beyond.errorMessage.isEmpty()) + val legacy = stub.readFile(ReadFileRequest.newBuilder().setPath(file.toString()).build()) + assertTrue(legacy.content.isEmpty) + assertTrue(legacy.errorMessage.contains("explicit byte limits")) + val page = + stub.readFile( + ReadFileRequest + .newBuilder() + .setPath(file.toString()) + .setMaxBytes(Long.MAX_VALUE) + .build(), + ) + assertEquals(FileSystemLimits.READ_BYTES, page.content.size()) + assertTrue(page.truncated) + } + + @Test + fun `requested scan depth is honored and excessive server depth is explicit`() = + runBlocking { + var dir = root + repeat(FileSystemLimits.SCAN_DEPTH + 1) { dir = Files.createDirectory(dir.resolve("d")) } + val limited = + stub.scanDirectory( + ScanDirectoryRequest + .newBuilder() + .setPath(root.toString()) + .setRecursive(true) + .setMaxDepth(2) + .build(), + ) + assertEquals(2, limited.entriesCount) + val failure = + assertFailsWith { + stub.scanDirectory( + ScanDirectoryRequest + .newBuilder() + .setPath(root.toString()) + .setRecursive(true) + .build(), + ) + } + assertEquals(Status.Code.RESOURCE_EXHAUSTED, failure.status.code) + } + + @Test + fun `filtered entries still count toward scan work ceiling`() = + runBlocking { + repeat(FileSystemLimits.SCAN_ENTRIES) { Files.createFile(root.resolve("$it.bin")) } + val failure = + assertFailsWith { + stub.scanDirectory( + ScanDirectoryRequest + .newBuilder() + .setPath(root.toString()) + .addExtensions("txt") + .build(), + ) + } + assertEquals(Status.Code.RESOURCE_EXHAUSTED, failure.status.code) + } + + @Test + fun `watch emits real changes and releases its slot after collection`() = + runBlocking { + repeat(34) { iteration -> + withTimeout(10_000) { + val received = + async { + val request = WatchFileChangesRequest.newBuilder().setPath(root.toString()).build() + stub.watchFileChanges(request).first() + } + // Repeated writes avoid relying on an internal registration hook or scheduler timing. + while (!received.isCompleted) { + delay(25) + Files.writeString(root.resolve("event-$iteration"), "changed") + } + assertTrue(received.await().path.startsWith(root.toString())) + } + } + } +} diff --git a/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/FileSystemDataProviderProxy.kt b/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/FileSystemDataProviderProxy.kt index 0901651cb3..d04df97331 100644 --- a/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/FileSystemDataProviderProxy.kt +++ b/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/FileSystemDataProviderProxy.kt @@ -14,6 +14,7 @@ import ai.rever.boss.plugin.api.FileSystemDataProvider import ai.rever.boss.plugin.api.NodeLoadingStateData import com.google.protobuf.ByteString import io.grpc.ManagedChannel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -21,6 +22,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import java.io.ByteArrayOutputStream import java.io.File /** @@ -254,15 +256,38 @@ class FileSystemDataProviderProxy( override suspend fun readFile(path: String): Result = withContext(Dispatchers.Default) { try { - val response = - fsStub.readFile( - ReadFileRequest.newBuilder().setPath(path).build(), - ) - if (response.errorMessage.isNotBlank()) { - Result.failure(Exception(response.errorMessage)) - } else { - Result.success(response.content.toStringUtf8()) - } + val content = ByteArrayOutputStream() + var expectedSize: Long? = null + do { + val response = + fsStub.readFile( + ReadFileRequest + .newBuilder() + .setPath(path) + .setOffsetBytes(content.size().toLong()) + .setMaxBytes(1_048_576) + .build(), + ) + check(response.errorMessage.isBlank()) { response.errorMessage } + check( + response.totalSizeBytes in 0..MAX_TEXT_BYTES.toLong() && + content.size() + response.content.size() <= MAX_TEXT_BYTES, + ) { + "Text files larger than 8 MiB cannot be read through the plugin provider" + } + check(expectedSize == null || expectedSize == response.totalSizeBytes) { + "File changed during read; retry" + } + expectedSize = response.totalSizeBytes + check(!response.truncated || !response.content.isEmpty) { + "File service returned an empty partial page" + } + response.content.writeTo(content) + } while (response.truncated) + check(content.size().toLong() == expectedSize) { "File changed during read; retry" } + Result.success(content.toString(Charsets.UTF_8)) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Result.failure(e) } @@ -348,3 +373,6 @@ class FileSystemDataProviderProxy( return sb.toString() } } + +// This provider returns a complete in-memory String. Larger files require a streaming editor API. +private const val MAX_TEXT_BYTES = 8_388_608 diff --git a/plugin-platform/plugin-api-ipc/src/desktopTest/kotlin/ai/rever/boss/plugin/ipc/FileSystemReadPagingTest.kt b/plugin-platform/plugin-api-ipc/src/desktopTest/kotlin/ai/rever/boss/plugin/ipc/FileSystemReadPagingTest.kt new file mode 100644 index 0000000000..6fe6a6c588 --- /dev/null +++ b/plugin-platform/plugin-api-ipc/src/desktopTest/kotlin/ai/rever/boss/plugin/ipc/FileSystemReadPagingTest.kt @@ -0,0 +1,86 @@ +package ai.rever.boss.plugin.ipc + +import ai.rever.boss.ipc.proto.services.FileSystemServiceGrpcKt +import ai.rever.boss.ipc.proto.services.ReadFileRequest +import ai.rever.boss.ipc.proto.services.ReadFileResponse +import com.google.protobuf.ByteString +import io.grpc.ManagedChannelBuilder +import io.grpc.ServerBuilder +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FileSystemReadPagingTest { + @Test + fun `UTF8 characters split across pages are decoded once`() = + runBlocking { + val expected = "a".repeat(1_048_575) + "🙂tail" + val bytes = expected.toByteArray() + val offsets = mutableListOf() + withService({ request -> + offsets.add(request.offsetBytes) + val start = request.offsetBytes.toInt() + val count = minOf(request.maxBytes.toInt(), bytes.size - start) + ReadFileResponse + .newBuilder() + .setContent(ByteString.copyFrom(bytes, start, count)) + .setTotalSizeBytes(bytes.size.toLong()) + .setTruncated(start + count < bytes.size) + .build() + }) { proxy -> + assertEquals(expected, proxy.readFile("test.txt").getOrThrow()) + } + assertEquals(listOf(0L, 1_048_576L), offsets) + } + + @Test + fun `changing file size and oversized files fail instead of returning partial text`() = + runBlocking { + withService({ request -> + ReadFileResponse + .newBuilder() + .setContent(ByteString.copyFromUtf8("a")) + .setTotalSizeBytes(if (request.offsetBytes == 0L) 2 else 3) + .setTruncated(request.offsetBytes == 0L) + .build() + }) { proxy -> assertTrue(proxy.readFile("test").isFailure) } + withService({ + ReadFileResponse.newBuilder().setTotalSizeBytes(8_388_609).build() + }) { proxy -> assertTrue(proxy.readFile("test").isFailure) } + } + + @Test + fun `empty partial pages and service errors terminate the read`() = + runBlocking { + withService({ ReadFileResponse.newBuilder().setTruncated(true).build() }) { proxy -> + assertTrue(proxy.readFile("test").isFailure) + } + withService({ ReadFileResponse.newBuilder().setErrorMessage("denied").build() }) { proxy -> + assertEquals("denied", proxy.readFile("test").exceptionOrNull()?.message) + } + } + + private suspend fun withService( + response: (ReadFileRequest) -> ReadFileResponse, + action: suspend (FileSystemDataProviderProxy) -> Unit, + ) { + val service = + object : FileSystemServiceGrpcKt.FileSystemServiceCoroutineImplBase() { + override suspend fun readFile(request: ReadFileRequest) = response(request) + } + val server = + ServerBuilder + .forPort(0) + .addService(service) + .build() + .start() + val channel = ManagedChannelBuilder.forAddress("127.0.0.1", server.port).usePlaintext().build() + try { + action(FileSystemDataProviderProxy(channel, channel)) + } finally { + channel.shutdownNow() + server.shutdownNow() + } + } +} From 3d26055d4fbd1cb7d604bf66fa60a723dbceedf6 Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:02:37 +0530 Subject: [PATCH 02/49] Verify filesystem limits across desktop platforms --- .github/workflows/filesystem-limits.yml | 40 +++++++++++++++++ .../service/filesystem/FileWatchRegistry.kt | 41 +++++++++++++++-- .../filesystem/FileSystemLimitsTest.kt | 45 +++++++++++++++++++ .../filesystem/RpcFailureDiagnostics.kt | 29 ++++++++++++ 4 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/filesystem-limits.yml create mode 100644 modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/RpcFailureDiagnostics.kt diff --git a/.github/workflows/filesystem-limits.yml b/.github/workflows/filesystem-limits.yml new file mode 100644 index 0000000000..ee8b0521d6 --- /dev/null +++ b/.github/workflows/filesystem-limits.yml @@ -0,0 +1,40 @@ +name: Filesystem resource limits + +on: + push: + branches: ['fix/security-filesystem-limits'] + pull_request: + paths: + - 'modules/boss-service-filesystem/**' + - 'modules/boss-ipc/src/main/proto/boss/ipc/v1/services/filesystem.proto' + - 'plugin-platform/plugin-api-ipc/**' + - '.github/workflows/filesystem-limits.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + filesystem: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6.0.0 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v6 + - name: Run filesystem and paging regressions + shell: bash + run: ./gradlew :boss-service-filesystem:test :plugin-platform:plugin-api-ipc:desktopTest :boss-service-filesystem:ktlintCheck :boss-service-filesystem:detekt :plugin-platform:plugin-api-ipc:ktlintCheck :plugin-platform:plugin-api-ipc:detekt --no-daemon + - uses: actions/upload-artifact@v7 + if: always() + with: + name: filesystem-limits-${{ matrix.os }} + path: | + modules/boss-service-filesystem/build/test-results + plugin-platform/plugin-api-ipc/build/test-results diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileWatchRegistry.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileWatchRegistry.kt index e1f40ba7c7..8680604ea7 100644 --- a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileWatchRegistry.kt +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileWatchRegistry.kt @@ -131,8 +131,8 @@ internal class FileWatchRegistry { enforceFileSystemLimit(keys.size < 1024, "File watch directory limit reached") try { keys.add(entry.register(service, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE)) - } catch (e: NoSuchFileException) { - if (entry == root) throw e + } catch (e: IOException) { + if (!disappeared(entry, e)) throw e } } return FileVisitResult.CONTINUE @@ -143,7 +143,7 @@ internal class FileWatchRegistry { exc: IOException, ): FileVisitResult { context.ensureActive() - if (exc is NoSuchFileException && file != root) return FileVisitResult.CONTINUE + if (disappeared(file, exc)) return FileVisitResult.CONTINUE throw exc } @@ -159,5 +159,40 @@ internal class FileWatchRegistry { }, ) } + + private fun disappeared( + path: Path, + failure: IOException, + ): Boolean = + path != root && + when (failure) { + is NoSuchFileException -> { + true + } + + else -> { + System.getProperty("os.name").startsWith("Windows") && confirmDeletion(path) + } + } + + private fun confirmDeletion(path: Path): Boolean { + // Windows can deny an open while deletion is pending. Only suppress the failure + // after an attribute read confirms absence; persistent permission failures still surface. + repeat(5) { + context.ensureActive() + Thread.sleep(10) + val absent = + try { + Files.readAttributes(path, BasicFileAttributes::class.java, NOFOLLOW_LINKS) + false + } catch (_: NoSuchFileException) { + true + } catch (_: IOException) { + false // The watch API can erase the Windows error type; only proven absence is suppressed. + } + if (absent) return true + } + return false + } } } diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt index f3c0e783c7..b62ec91c8c 100644 --- a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt @@ -16,6 +16,10 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import java.io.RandomAccessFile import java.nio.file.Files +import java.nio.file.attribute.AclEntry +import java.nio.file.attribute.AclEntryPermission +import java.nio.file.attribute.AclEntryType +import java.nio.file.attribute.AclFileAttributeView import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.test.AfterTest @@ -31,6 +35,7 @@ class FileSystemLimitsTest { ServerBuilder .forPort(0) .addService(FileSystemServiceImpl()) + .intercept(RpcFailureDiagnostics) .build() .start() private val channel = ManagedChannelBuilder.forAddress("127.0.0.1", server.port).usePlaintext().build() @@ -86,6 +91,46 @@ class FileSystemLimitsTest { ) } + @Test + fun `persistent Windows directory permission failures remain visible`() = + runBlocking { + if (!Platform.isWindows()) return@runBlocking + val denied = Files.createDirectory(root.resolve("denied")) + Files.writeString(denied.resolve("sentinel"), "requires directory enumeration") + val view = Files.getFileAttributeView(denied, AclFileAttributeView::class.java) + val original = view.acl + val principal = + denied.fileSystem.userPrincipalLookupService.lookupPrincipalByName(System.getProperty("user.name")) + try { + val deny = + AclEntry + .newBuilder() + .setType(AclEntryType.DENY) + .setPrincipal(principal) + .setPermissions(AclEntryPermission.LIST_DIRECTORY) + .build() + view.acl = listOf(deny) + original + assertFailsWith { + Files.newDirectoryStream(denied).use { it.toList() } + } + assertFailsWith { + withTimeout(5000) { + stub + .watchFileChanges( + WatchFileChangesRequest + .newBuilder() + .setPath(root.toString()) + .setRecursive(true) + .build(), + ).first() + } + } + assertTrue(Files.exists(denied)) + } finally { + view.acl = original + } + } + @Test fun `recursive watch survives disappearing descendants and continues observing the root`() = runBlocking { diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/RpcFailureDiagnostics.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/RpcFailureDiagnostics.kt new file mode 100644 index 0000000000..57093d9d8c --- /dev/null +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/RpcFailureDiagnostics.kt @@ -0,0 +1,29 @@ +package ai.rever.boss.service.filesystem + +import io.grpc.ForwardingServerCall +import io.grpc.Metadata +import io.grpc.ServerCall +import io.grpc.ServerCallHandler +import io.grpc.ServerInterceptor +import io.grpc.Status + +/** Keep the actual server exception in CI reports; a client receives only UNKNOWN for unexpected IO. */ +internal object RpcFailureDiagnostics : ServerInterceptor { + override fun interceptCall( + call: ServerCall, + headers: Metadata, + next: ServerCallHandler, + ): ServerCall.Listener = + next.startCall( + object : ForwardingServerCall.SimpleForwardingServerCall(call) { + override fun close( + status: Status, + trailers: Metadata, + ) { + if (status.code == Status.Code.UNKNOWN) status.cause?.printStackTrace() + super.close(status, trailers) + } + }, + headers, + ) +} From cc610e5e9d98aa730d176e57b95c61d9e7f46ffa Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:18:16 +0530 Subject: [PATCH 03/49] Exercise persistent Windows watch errors with an exclusive directory handle --- .../filesystem/FileSystemLimitsTest.kt | 64 ++++++++++++------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt index b62ec91c8c..62b02675eb 100644 --- a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt @@ -4,7 +4,12 @@ import ai.rever.boss.ipc.proto.services.FileSystemServiceGrpcKt import ai.rever.boss.ipc.proto.services.ReadFileRequest import ai.rever.boss.ipc.proto.services.ScanDirectoryRequest import ai.rever.boss.ipc.proto.services.WatchFileChangesRequest +import com.sun.jna.Function +import com.sun.jna.Native +import com.sun.jna.NativeLibrary import com.sun.jna.Platform +import com.sun.jna.Pointer +import com.sun.jna.WString import io.grpc.ManagedChannelBuilder import io.grpc.ServerBuilder import io.grpc.Status @@ -14,12 +19,13 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import java.io.IOException import java.io.RandomAccessFile import java.nio.file.Files -import java.nio.file.attribute.AclEntry -import java.nio.file.attribute.AclEntryPermission -import java.nio.file.attribute.AclEntryType -import java.nio.file.attribute.AclFileAttributeView +import java.nio.file.LinkOption.NOFOLLOW_LINKS +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE +import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.test.AfterTest @@ -92,26 +98,14 @@ class FileSystemLimitsTest { } @Test - fun `persistent Windows directory permission failures remain visible`() = + fun `persistent Windows directory sharing failures remain visible`() = runBlocking { if (!Platform.isWindows()) return@runBlocking val denied = Files.createDirectory(root.resolve("denied")) - Files.writeString(denied.resolve("sentinel"), "requires directory enumeration") - val view = Files.getFileAttributeView(denied, AclFileAttributeView::class.java) - val original = view.acl - val principal = - denied.fileSystem.userPrincipalLookupService.lookupPrincipalByName(System.getProperty("user.name")) - try { - val deny = - AclEntry - .newBuilder() - .setType(AclEntryType.DENY) - .setPrincipal(principal) - .setPermissions(AclEntryPermission.LIST_DIRECTORY) - .build() - view.acl = listOf(deny) + original - assertFailsWith { - Files.newDirectoryStream(denied).use { it.toList() } + withExclusiveDirectory(denied) { + assertTrue(Files.readAttributes(denied, BasicFileAttributes::class.java, NOFOLLOW_LINKS).isDirectory) + denied.fileSystem.newWatchService().use { watcher -> + assertFailsWith { denied.register(watcher, ENTRY_CREATE) } } assertFailsWith { withTimeout(5000) { @@ -125,9 +119,10 @@ class FileSystemLimitsTest { ).first() } } - assertTrue(Files.exists(denied)) - } finally { - view.acl = original + } + // The same operation succeeds after releasing the deliberately conflicting handle. + denied.fileSystem.newWatchService().use { watcher -> + assertTrue(denied.register(watcher, ENTRY_CREATE).isValid) } } @@ -271,3 +266,24 @@ class FileSystemLimitsTest { } } } + +private suspend fun withExclusiveDirectory( + directory: Path, + action: suspend () -> Unit, +) { + val kernel = NativeLibrary.getInstance("kernel32") + // FILE_LIST_DIRECTORY, no sharing, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS. + // Attribute reads remain allowed, so the watch registry can distinguish this from deletion. + val handle = + kernel.getFunction("CreateFileW", Function.ALT_CONVENTION).invokePointer( + arrayOf(WString(directory.toString()), 1, 0, null, 3, 0x02000000, null), + ) + check(handle != null && Pointer.nativeValue(handle) != -1L) { + "Cannot acquire exclusive directory handle: ${Native.getLastError()}" + } + try { + action() + } finally { + check(kernel.getFunction("CloseHandle", Function.ALT_CONVENTION).invokeInt(arrayOf(handle)) != 0) + } +} From 3ba30192ebda38c60c9108d5e71faa8a9d160556 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sat, 12 Sep 2026 20:58:07 -0700 Subject: [PATCH 04/49] fix(filesystem): preserve Windows extended-path reads --- .../boss/service/filesystem/RegularFileReader.kt | 11 ++++++++++- .../boss/service/filesystem/FileSystemLimitsTest.kt | 13 +++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt index 427046fb5a..e7ccc4a3b4 100644 --- a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt @@ -117,7 +117,7 @@ private class WindowsRegularFile( // OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, non-inheritable handle, shared read/write/delete. val opened = kernel.getFunction("CreateFileW", Function.ALT_CONVENTION).invokePointer( - arrayOf(WString(path.toAbsolutePath().toString()), 0x80000000.toInt(), 7, null, 3, 0x00200000, null), + arrayOf(WString(windowsExtendedPath(path)), 0x80000000.toInt(), 7, null, 3, 0x00200000, null), ) if (opened == null || Pointer.nativeValue(opened) == -1L) throw nativeReadError("Open") handle = opened @@ -165,3 +165,12 @@ private class WindowsRegularFile( } private fun nativeReadError(operation: String) = IOException("$operation failed (OS error ${Native.getLastError()})") + +private fun windowsExtendedPath(path: Path): String { + val absolute = path.toAbsolutePath().normalize().toString() + return when { + absolute.startsWith("\\\\?\\") -> absolute + absolute.startsWith("\\\\") -> "\\\\?\\UNC\\" + absolute.removePrefix("\\\\") + else -> "\\\\?\\" + absolute + } +} diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt index 62b02675eb..35e269299c 100644 --- a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt @@ -156,6 +156,19 @@ class FileSystemLimitsTest { } } + @Test + fun `Windows reads retain support for paths beyond MAX_PATH`() = + runBlocking { + if (!Platform.isWindows()) return@runBlocking + var directory = root + while (directory.toString().length < 300) directory = directory.resolve("long-directory-component") + Files.createDirectories(directory) + val file = Files.writeString(directory.resolve("content.txt"), "long path content") + val response = stub.readFile(ReadFileRequest.newBuilder().setPath(file.toString()).build()) + assertTrue(response.errorMessage.isEmpty(), response.errorMessage) + assertEquals("long path content", response.content.toStringUtf8()) + } + @Test fun `sparse files support long offsets and refuse unbounded legacy reads`() = runBlocking { From 9047ffa1b307dcf4800ca88a732359da6ca77084 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sat, 12 Sep 2026 21:02:17 -0700 Subject: [PATCH 05/49] fix(filesystem): preserve bounded reads through authorized file links --- .../boss/service/filesystem/FileSystemServiceImpl.kt | 4 +++- .../boss/service/filesystem/FileSystemLimitsTest.kt | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt index 6185601e6d..0930f1accc 100644 --- a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt @@ -84,7 +84,9 @@ class FileSystemServiceImpl : FileSystemServiceGrpcKt.FileSystemServiceCoroutine .takeIf { it > 0 } ?.coerceAtMost(FileSystemLimits.READ_BYTES.toLong()) ?.toInt() ?: FileSystemLimits.READ_BYTES - openRegularFile(file.toPath()).use { reader -> + val resolved = file.toPath().toRealPath() + validatePath(resolved.toString()) + openRegularFile(resolved).use { reader -> val totalSize = reader.size val bytes = reader.readPage(request.offsetBytes, maximum + 1) currentCoroutineContext().ensureActive() diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt index 35e269299c..7a2c33a8d8 100644 --- a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt @@ -156,6 +156,16 @@ class FileSystemLimitsTest { } } + @Test + fun `reads through file links resolve the target before the bounded open`() = + runBlocking { + val target = Files.writeString(root.resolve("target"), "linked content") + val link = Files.createSymbolicLink(root.resolve("file-link"), target) + val response = stub.readFile(ReadFileRequest.newBuilder().setPath(link.toString()).build()) + assertTrue(response.errorMessage.isEmpty(), response.errorMessage) + assertEquals("linked content", response.content.toStringUtf8()) + } + @Test fun `Windows reads retain support for paths beyond MAX_PATH`() = runBlocking { From cc81ef6b99e75cafcab7a38242d3c89c78b46c93 Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:44:31 +0530 Subject: [PATCH 06/49] ci: run full validation on security repair branches --- .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 8f85dd81bf..c9d24df288 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: 🔨 Build & Test on: push: - branches: [ main, develop, dev ] + branches: [ main, develop, dev, 'fix/security-*', 'fix/filesystem-service-*' ] pull_request: branches: [ main, dev ] workflow_dispatch: From c366feab4feff9b55158c8b24727650a3b3a23ea Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:22:43 +0530 Subject: [PATCH 07/49] test(filesystem): separate watch delivery deadlines from Windows fixture IO --- .../filesystem/FileSystemLimitsTest.kt | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt index 7a2c33a8d8..282f80f7b9 100644 --- a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt @@ -14,10 +14,13 @@ import io.grpc.ManagedChannelBuilder import io.grpc.ServerBuilder import io.grpc.Status import io.grpc.StatusException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import java.io.IOException import java.io.RandomAccessFile @@ -129,8 +132,12 @@ class FileSystemLimitsTest { @Test fun `recursive watch survives disappearing descendants and continues observing the root`() = runBlocking { - withTimeout(15_000) { + // Windows CI spent 55 seconds in synchronous fixture churn. Give fixture I/O + // its own budget; retain a separate 15-second deadline for actual watch delivery. + withTimeout(90_000) { val sentinel = root.resolve("sentinel") + val readiness = root.resolve("watch-ready") + val ready = CompletableDeferred() val received = async { stub @@ -140,19 +147,32 @@ class FileSystemLimitsTest { .setPath(root.toString()) .setRecursive(true) .build(), - ).first { it.path == sentinel.toString() } + ).first { + if (it.path == readiness.toString()) ready.complete(Unit) + it.path == sentinel.toString() + } + } + withTimeout(15_000) { + while (!ready.isCompleted) { + withContext(Dispatchers.IO) { Files.writeString(readiness, "ready") } + delay(25) + } + } + withContext(Dispatchers.IO) { + repeat(100) { index -> + val transient = Files.createDirectory(root.resolve("transient-$index")) + Files.createDirectory(transient.resolve("child")) + transient.toFile().deleteRecursively() + delay(5) } - repeat(100) { index -> - val transient = Files.createDirectory(root.resolve("transient-$index")) - Files.createDirectory(transient.resolve("child")) - transient.toFile().deleteRecursively() - delay(5) } - while (!received.isCompleted) { - Files.writeString(sentinel, "still watching") - delay(50) + withTimeout(15_000) { + while (!received.isCompleted) { + withContext(Dispatchers.IO) { Files.writeString(sentinel, "still watching") } + delay(50) + } + assertEquals(sentinel.toString(), received.await().path) } - assertEquals(sentinel.toString(), received.await().path) } } From 3193f34b6deb90bbd2a4c149deb17866c4bde60d Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 18:40:46 -0700 Subject: [PATCH 08/49] fix(filesystem): use ARM64 no-follow flag for bounded reads --- .../ai/rever/boss/service/filesystem/RegularFileReader.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt index e7ccc4a3b4..28a1e6d1f9 100644 --- a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/RegularFileReader.kt @@ -70,8 +70,9 @@ private class PosixRegularFile( init { // O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC. Values from Darwin fcntl.h - // and Linux asm-generic/fcntl.h (only the supported 64-bit ABIs above). - val flags = if (Platform.isMac()) 0x0004 or 0x0100 or 0x01000000 else 0x0800 or 0x20000 or 0x80000 + // and Linux asm-generic/fcntl.h / arch/arm64/include/uapi/asm/fcntl.h. + val noFollow = if (Platform.ARCH == "aarch64") 0x8000 else 0x20000 + val flags = if (Platform.isMac()) 0x0004 or 0x0100 or 0x01000000 else 0x0800 or noFollow or 0x80000 descriptor = libc.getFunction("open").invokeInt(arrayOf(path.toString(), flags)) if (descriptor < 0) throw nativeReadError("Open") var initialized = false From c5e5e6f667df41114bd325eb3066e025cb0f4756 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 18:42:57 -0700 Subject: [PATCH 09/49] test(filesystem): pin native leaf-link refusal --- .../boss/service/filesystem/FileSystemLimitsTest.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt index 282f80f7b9..647736d05f 100644 --- a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt @@ -57,6 +57,15 @@ class FileSystemLimitsTest { root.toFile().deleteRecursively() } + @Test + fun `native POSIX open refuses a replaced leaf link`() { + if (Platform.isWindows()) return + val target = Files.writeString(root.resolve("native-target"), "content") + openRegularFile(target).use { assertEquals(7L, it.size) } + val link = Files.createSymbolicLink(root.resolve("native-link"), target) + assertFailsWith { openRegularFile(link).use { } } + } + @Test fun `native open rejects a FIFO without waiting for a writer`() { if (Platform.isWindows()) return From 16fc013b27e0e3f1aa128214fe8cd19f531145f1 Mon Sep 17 00:00:00 2001 From: Shivanshu Date: Mon, 14 Sep 2026 13:40:24 +0530 Subject: [PATCH 10/49] Respect horizontal scrollers and commit trackpad navigation on release --- AGENTS.md | 49 +- .../settings/sections/FluckBrowserSettings.kt | 10 + .../desktopMain/kotlin/ai/rever/boss/main.kt | 3 + .../boss/plugin/browser/BrowserHandleImpl.kt | 18 +- .../plugin/browser/BrowserSwipeNavBridge.kt | 20 +- .../plugin/browser/BrowserSwipeNavScript.kt | 11 +- .../browser/MacOSScrollGesturePhases.kt | 460 ++++++++++++++++++ .../resources/browser/swipe-nav.js | 183 ++++--- .../plugin/browser/BrowserSwipeNavTest.kt | 46 +- .../browser/MacOSScrollGesturePhasesTest.kt | 93 ++++ scripts/test/test-swipe-nav.js | 227 +++++++-- 11 files changed, 913 insertions(+), 207 deletions(-) create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhasesTest.kt diff --git a/AGENTS.md b/AGENTS.md index 8058909f48..c304aba844 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -794,7 +794,8 @@ restart. There is no Settings row and no per-site exclusion. A two-finger horizontal trackpad swipe navigates back/forward. It is detected **inside the page** (`BrowserSwipeNavScript` + `swipe-nav.js`), because under `HARDWARE_ACCELERATED` the browser is a -native surface and neither Compose nor AWT sees the wheel. JxBrowser's +native surface and Compose does not see the wheel. JxBrowser's AWT callback sees deltas but has +already lost the native finger and momentum phases. JxBrowser's `enableOverscrollHistoryNavigation` does NOT provide this - measured 2026-08-28, it does nothing for a trackpad in either rendering mode, because it is a touchscreen feature. @@ -803,17 +804,21 @@ vertical measured as a path length and horizontal as net displacement. Chrome's are fractions of the trackpad from `NSTouch.normalizedPosition`, which a page cannot see, so those carry over as the same fractions of the commit distance. -**It commits at the end of the gesture, not on crossing the commit distance** - and "end of -gesture" is literally `GESTURE_GAP_MS` (120ms) with no wheel event, because AWT does not surface -NSEvent's scroll phases and a time gap is the only segmentation signal there is. So it is not -release: holding past the line and simply STOPPING, fingers still down, commits after 120ms too. -The window in which reversing still cancels is 120ms of continuous motion, not "until you lift". -The decision reads the LAST horizontal position, so easing back below the line cancels. - -That makes `GESTURE_GAP_MS` do three jobs at once: segmenting one gesture from the next, setting a -floor on commit latency, and (as the minimum possible gap between two gesture ends) bounding -`SWIPE_NAV_DEBOUNCE_MS` from above. Raising or lowering it touches all three, and -`BrowserSwipeNavTest` reads it out of the script so the third one fails loudly. +**It commits on a real CoreGraphics Ended phase, never on an inactivity timeout.** A listen-only +session event tap assigns each finger sequence an id and accumulates its final point deltas. The +page latches that id while deciding scroll ownership; only the matching native release can decide. +A stationary hold therefore remains cancellable indefinitely. Cancelled phases reset without +navigating, and momentum has no active finger id and is ignored. Native final displacement is +authoritative at release so renderer/native queue reordering cannot hide a late easing-back or +reversal. If Input Monitoring preflight fails the feature fails closed and Settings shows how to +grant access; this path never requests permission itself. + +One cross-process ordering limit remains: JxBrowser drops the AWT event timestamp before building +the renderer wheel event. The epoch check can reject an event Chromium timestamps before the new +native Begin, but a renderer backlog could stamp an old event after that Begin and associate it +with the new id. Native final displacement still prevents a delayed reversal from committing; the +remaining cross-gesture attribution case needs real backlog testing before adding a custom FIFO +between the CoreGraphics tap and JxBrowser's input callback. **Past the commit distance, vertical drift stops cancelling** (`reachedCommit`). Vertical is a path length and only ever grows, so every event after the crossing was one more chance to cancel a swipe @@ -822,24 +827,8 @@ easing back or reversing can still cancel. Native swipe-back behaves the same wa **Two host-side windows, for two different things** (`BrowserSwipeNavBridge.kt`). `SWIPE_NAV_DEBOUNCE_MS` (32ms, any direction) catches a double-dispatch bug in the bridge. -`SWIPE_NAV_REPEAT_MS` (400ms, same direction only) is the paused-drag guard: a slow drag that -hesitates past `GESTURE_GAP_MS` with the fingers down is two gestures to the script and would -navigate back twice. That guard cannot live in the page - the first commit navigates the tab and -the script's state dies with the document. The cost is that two intentional same-direction swipes -under 400ms apart become one; that is the deliberate trade, because a dropped swipe is retryable -and an extra step back may not be, since the forward entry need not survive a redirect. A reversal -is never held for the repeat window. - -**Momentum phase costs latency and nothing else.** A `CGEvent` tap on this hardware (measured -2026-09-02) shows macOS emitting momentum-phase scroll for 180-870ms after the fingers lift, -carrying 325-2500px of horizontal travel. Whether Chromium forwards those to the renderer as -`wheel` events is NOT confirmed: if it does, each one re-arms the end-of-gesture timer and a flick -commits at end-of-momentum instead of at release. It cannot change the ANSWER - a tail runs the -flick's own direction, so it can neither reverse nor ease back, and `reachedCommit` is what closed -the remaining path, a tail's `deltaY` tripping the vertical tiers. Synthetic phase-tagged events -cannot settle the forwarding question - `CGEventPost` from another process never reaches the -layered native browser surface, and does not even enter the session event stream - so it needs one -real flick against a recording `wheel` listener. +`SWIPE_NAV_REPEAT_MS` (400ms, same direction only) remains defense in depth against duplicate +bridge delivery across a navigation. A reversal is held only for the shorter debounce window. **Off switch**: `Settings > Browser > Trackpad`, stored in `~/.boss/swipe-nav.json`, or `BOSS_BROWSER_SWIPE_NAV=false` (also `0` / `no` / `off`). The environment wins, and the Settings row diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt index 1a18d3ef0a..da41e8c7f2 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt @@ -18,6 +18,7 @@ import ai.rever.boss.html.HtmlFileOpenMode import ai.rever.boss.html.HtmlFileSettingsManager import ai.rever.boss.plugin.browser.BrowserSettings import ai.rever.boss.plugin.browser.BrowserSettingsManager +import ai.rever.boss.plugin.browser.MacOSScrollGesturePhases import ai.rever.boss.plugin.ui.BossAlertDialog import ai.rever.boss.terminal.ExistingSplitTargetMode import ai.rever.boss.terminal.TerminalLinkOpenMode @@ -99,6 +100,7 @@ fun FluckBrowserSettings() { // stale value after the other one changes it. val stored by SwipeNavSettingsManager.settings.collectAsState() val swipeEnabled = parseSwipeNavEnabled(envOverride) ?: stored.enabled + val phaseAvailable = MacOSScrollGesturePhases.isAvailable() SettingsToggle( label = "Two-finger swipe navigation", checked = swipeEnabled, @@ -110,6 +112,14 @@ fun FluckBrowserSettings() { envOverride .takeIf { envOwned } ?.let { "Set by ${SwipeNavSettingsManager.KEY}=$it in the environment" } + ?: ( + if (!phaseAvailable) { + "Unavailable: allow BOSS under System Settings > Privacy & Security > " + + "Input Monitoring, then restart BOSS." + } else { + null + } + ) ?: "Swipe right with two fingers to go back, left to go forward.", ) } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt index 48195f7c2a..4350a7784b 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt @@ -227,6 +227,9 @@ fun main(args: Array) { ChromiumFlagsSettingsManager.applyToSystemProperties() ai.rever.boss.config.SwipeNavSettingsManager .publish() + // Start release detection before the browser plugin's home surface can receive gestures. + ai.rever.boss.plugin.browser.MacOSScrollGesturePhases + .ensureStarted() ai.rever.boss.config.AutoPipSettingsManager .publish() diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt index 3c9fe7eca3..1e6198f367 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt @@ -401,8 +401,11 @@ internal class BrowserHandleImpl( // the navigation it just caused, which the page-side script cannot - see [SwipeNavGate]. private val swipeNavGate = SwipeNavGate() + private val swipeGestureClaim = MacOSScrollGesturePhases.register(::onSwipeGestureEnded) + /** Receives committed two-finger swipes from the page. See [BrowserSwipeNavScript]. */ - private val swipeNavBridge = BrowserSwipeNavBridge(onNavigate = ::onSwipeNavigate) + private val swipeNavBridge = + BrowserSwipeNavBridge(onNavigate = ::onSwipeNavigate, gestureClaim = swipeGestureClaim) private val menuContextAuthority = BrowserMenuContextAuthority() private val disposed = AtomicBoolean(false) @@ -1511,6 +1514,7 @@ internal class BrowserHandleImpl( browser.on(BrowserClosed::class.java) { menuContextAuthority.invalidate() logger.debug(LogCategory.BROWSER, "Browser closed", mapOf("handleId" to id)) + swipeGestureClaim.close() // A browser can close without a dispose() call (a crashed renderer, an engine recycle). // Call dispose() to ensure all scopes are cancelled and the handle is unregistered. // It is idempotent, so a dispose() from the UI will safely no-op. @@ -2045,6 +2049,17 @@ internal class BrowserHandleImpl( } } + /** Delivers a real native release to the document which accumulated the matching sequence. */ + private fun onSwipeGestureEnded(end: ScrollGestureEnd) { + if (!isValid) return + pageInjectScope.launch(pageInjectDispatcher) { + val releaseFunction = "window.${BrowserSwipeNavScript.RELEASE_PROPERTY}" + val releaseArguments = "'${end.id}', ${end.cancelled}, ${end.accumX}, ${end.rejected}" + val statement = "$releaseFunction && $releaseFunction($releaseArguments);" + runCatching { browser.mainFrame().ifPresent { it.executeJavaScript(statement) } } + } + } + // ============================================================ // PAGE EVENT CHANNEL // ============================================================ @@ -4240,6 +4255,7 @@ internal class BrowserHandleImpl( override fun dispose() { menuContextAuthority.invalidate() audioSource.close() + swipeGestureClaim.close() // Synchronously, and before the guard below: invokeLater would let browser.close() run // first, and closing the browser under a still-attached Swing view is exactly the // ordering that leaves an undecorated always-on-top window on screen with nothing able diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavBridge.kt index ad61aa566d..9075aac4db 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavBridge.kt @@ -29,6 +29,7 @@ internal enum class SwipeNavDirection { BACK, FORWARD } */ internal class BrowserSwipeNavBridge( private val onNavigate: (SwipeNavDirection) -> Unit, + private val gestureClaim: ScrollGestureClaim? = null, private val nowMs: () -> Long = System::currentTimeMillis, ) { private val logger = BossLogger.forComponent("BrowserSwipeNavBridge") @@ -54,6 +55,10 @@ internal class BrowserSwipeNavBridge( } } + /** Native finger-contact sequence currently producing renderer wheel events. */ + @JsAccessible + fun activeGestureId(): String? = gestureClaim?.currentGestureToken() + /** * Log at most one failure per minute. * @@ -105,18 +110,11 @@ internal data class SwipeNavCommit( * Two windows, because two different things are being refused. * * [SWIPE_NAV_DEBOUNCE_MS] catches a double-fire *bug* in this bridge or its caller, in either - * direction. The page cannot produce a real second gesture that fast: `swipe-nav.js`'s `decide()` - * calls this at most once per gesture END, and two gesture ends are always at least the script's - * own `GESTURE_GAP_MS` apart, because that quiet gap IS how the script tells one gesture from the - * next. So the value belongs strictly below that floor and above a same-frame double-dispatch, - * with enough headroom that host-side clock jitter cannot eat the difference. + * direction. The page calls this at most once per native gesture id, so this only needs enough + * headroom to catch a same-frame double-dispatch. * - * [SWIPE_NAV_REPEAT_MS] refuses a SAME-DIRECTION repeat, and it is the one guard against a paused - * drag. The script segments gestures on 120ms of quiet, and 120ms of quiet with the fingers still - * down is byte-identical to a lift: a slow deliberate drag that hesitates mid-swipe is two - * gestures to the script and would navigate back twice. That guard cannot live in the page, - * because the commit navigates the tab and the script's state dies with the document - the second - * half of the drag lands in a freshly loaded script that has never heard of the first. + * [SWIPE_NAV_REPEAT_MS] refuses a SAME-DIRECTION repeat as defense in depth across the navigation + * which destroys the committing page's state. * * The trade is deliberate and it is not symmetric. Two intentional same-direction swipes less * than [SWIPE_NAV_REPEAT_MS] apart are dropped, and the user swipes again. An unwanted extra step diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt index 6d8cd8fee2..41b35d0de2 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt @@ -8,10 +8,10 @@ import ai.rever.boss.utils.logging.LogCategory /** * The in-page two-finger swipe detector, as JavaScript. * - * A macOS trackpad swipe cannot be observed from the JVM side of this app. Under the default - * `HARDWARE_ACCELERATED` rendering mode the browser is a native surface layered over the window - * rather than a component in the Compose scene, so Compose never sees the wheel (the same fact - * [shouldAllowPinch] documents at length). Chromium's own overscroll history navigation is already + * Under the default `HARDWARE_ACCELERATED` rendering mode the browser is a native surface layered + * over the window, so Compose never sees the wheel. JxBrowser's native surface does forward AWT + * wheel callbacks, but that layer has already discarded macOS finger and momentum phases. + * Chromium's own overscroll history navigation is already * switched on in `BrowserServiceImpl` and does nothing for a trackpad in EITHER rendering mode - * measured, see the note at that call site; it is a touchscreen feature. What is left is the * renderer: it sees every wheel event, because that is how pages scroll. @@ -33,6 +33,9 @@ internal object BrowserSwipeNavScript { /** Property the host pushes navigability onto. Matched by the script. */ const val STATE_PROPERTY: String = "__bossSwipeNavState" + /** Function invoked by the native phase observer when fingers leave the trackpad. */ + const val RELEASE_PROPERTY: String = "__bossSwipeNavRelease" + /** Lazily-loaded gesture script (cached for the process lifetime). */ val source: String by lazy { loadResource("/browser/swipe-nav.js") } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt new file mode 100644 index 0000000000..0be35e3a7a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt @@ -0,0 +1,460 @@ +package ai.rever.boss.plugin.browser + +import ai.rever.boss.utils.SystemUtils +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory +import com.sun.jna.Callback +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.NativeLibrary +import com.sun.jna.Pointer +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +/** The part of a macOS scroll sequence which comes from fingers still touching the trackpad. */ +internal data class ScrollGestureSnapshot( + val id: Long, + val active: Boolean, + val observable: Boolean = active, + val accumX: Double = 0.0, + val verticalPath: Double = 0.0, + val direction: Int = 0, + val rejected: Boolean = false, + val reversed: Boolean = false, + val reachedCommit: Boolean = false, + val mayBegin: Boolean = false, + val beganAtEpochMs: Long = 0, + val claimantIds: Set = emptySet(), +) + +internal data class ScrollGestureEnd( + val id: Long, + val cancelled: Boolean, + val accumX: Double, + val verticalPath: Double, + val rejected: Boolean, + val reversed: Boolean, + val claimantIds: Set, +) + +/** Claim [claimantId] only while this exact native sequence is still active. */ +internal fun claimScrollGesture( + current: ScrollGestureSnapshot, + claimantId: Long, +): ScrollGestureSnapshot? = + when { + !current.active -> null + claimantId in current.claimantIds -> current + else -> current.copy(claimantIds = current.claimantIds + claimantId) + } + +@Suppress("LongParameterList") // Mirrors the independent numeric fields carried by one CGEvent. +internal fun scrollGestureTransition( + current: ScrollGestureSnapshot, + phase: Long, + momentumPhase: Long, + dx: Double = 0.0, + dy: Double = 0.0, + nowEpochMs: Long = 0, +): Pair { + val terminalDx = if (momentumPhase == CG_SCROLL_PHASE_NONE) dx else 0.0 + val terminalDy = if (momentumPhase == CG_SCROLL_PHASE_NONE) dy else 0.0 + return when { + phase and CG_SCROLL_PHASE_CANCELLED != 0L -> { + finish(current, terminalDx, terminalDy, cancelled = true) + } + + phase and CG_SCROLL_PHASE_ENDED != 0L -> { + finish(current, terminalDx, terminalDy, cancelled = false) + } + + momentumPhase != CG_SCROLL_PHASE_NONE -> { + if (current.active) { + finish(current, 0.0, 0.0, cancelled = true) + } else { + current.copy(observable = false) to null + } + } + + phase and CG_SCROLL_PHASE_MAY_BEGIN != 0L -> { + ScrollGestureSnapshot( + current.id + 1, + true, + mayBegin = true, + beganAtEpochMs = nowEpochMs, + ) to null + } + + phase and CG_SCROLL_PHASE_BEGAN != 0L -> { + accumulate( + if (current.active && current.mayBegin) { + current.copy(mayBegin = false) + } else { + ScrollGestureSnapshot(current.id + 1, true, beganAtEpochMs = nowEpochMs) + }, + dx, + dy, + ) to null + } + + phase and CG_SCROLL_PHASE_CHANGED != 0L -> { + (if (current.active) accumulate(current, dx, dy) else current) to null + } + + else -> { + current to null + } + } +} + +private fun finish( + current: ScrollGestureSnapshot, + dx: Double, + dy: Double, + cancelled: Boolean, +): Pair { + if (!current.active) return current to null + val final = accumulate(current, dx, dy).copy(active = false, observable = false) + return final to + ScrollGestureEnd( + final.id, + cancelled, + final.accumX, + final.verticalPath, + final.rejected, + final.reversed, + final.claimantIds, + ) +} + +@Suppress("CyclomaticComplexMethod") // Each branch is one documented Chrome cancellation tier. +private fun accumulate( + current: ScrollGestureSnapshot, + dx: Double, + dy: Double, +): ScrollGestureSnapshot { + val x = current.accumX + dx + val vertical = current.verticalPath + kotlin.math.abs(dy) + val direction = + current.direction.takeIf { it != 0 } ?: if (x < 0) { + -1 + } else if (x > 0) { + 1 + } else { + 0 + } + val reversed = direction != 0 && x != 0.0 && (if (x < 0) -1 else 1) != direction + val xDelta = kotlin.math.abs(x) + val rejected = + current.rejected || reversed || ( + !current.reachedCommit && + (vertical > 2 * xDelta || (vertical * 1.3 > xDelta && vertical > 90 * .125) || vertical > 90 * 3) + ) + return current.copy( + active = true, + observable = true, + accumX = x, + verticalPath = vertical, + direction = direction, + rejected = rejected, + reversed = current.reversed || reversed, + reachedCommit = current.reachedCommit || (!rejected && xDelta >= 90), + ) +} + +/** + * Observes real macOS scroll phases. The event tap is listen-only and is created only after the + * non-prompting preflight succeeds; Boss never requests Input Monitoring from this path. + */ +// The native tap lifecycle and per-browser claimant registry intentionally share one atomic owner. +@Suppress("TooManyFunctions") +internal object MacOSScrollGesturePhases { + private val logger = BossLogger.forComponent("MacOSScrollGesturePhases") + private val listeners = java.util.concurrent.ConcurrentHashMap Unit>() + private val nextClaimantId = AtomicLong(0) + private val snapshot = AtomicReference(ScrollGestureSnapshot(0, false)) + private val started = AtomicReference(null) + private val retainedCallback = AtomicReference(null) + private val eventTap = AtomicReference(null) + private val runLoop = AtomicReference(null) + private val failuresLogged = AtomicLong(0) + + init { + System.setProperty(PHASE_PROPERTY, "unavailable") + } + + fun ensureStarted() = startIfAvailable() + + fun register(onEnded: (ScrollGestureEnd) -> Unit): ScrollGestureClaim { + val claimantId = nextClaimantId.incrementAndGet() + listeners[claimantId] = onEnded + startIfAvailable() + return ScrollGestureClaim(claimantId) + } + + internal fun claimCurrent(claimantId: Long): String? { + while (true) { + val before = snapshot.get() + val claimed = claimScrollGesture(before, claimantId) ?: return null + if (claimed === before || snapshot.compareAndSet(before, claimed)) { + return "${claimed.id}:${claimed.beganAtEpochMs}" + } + } + } + + internal fun unregister(claimantId: Long) { + listeners.remove(claimantId) + } + + /** True when release detection can run; false also means preflight declined without prompting. */ + fun isAvailable(): Boolean { + startIfAvailable() + return started.get() == true + } + + private fun startIfAvailable() { + if (!SystemUtils.isMacOS || started.get() != null) return + synchronized(this) { + if (started.get() != null) return + val available = + runCatching { Native.load("ApplicationServices", ApplicationServices::class.java) } + .mapCatching { api -> + if (!api.CGPreflightListenEventAccess()) return@mapCatching false + val callback = + ScrollEventCallback { _, type, event, _ -> + when { + type == CG_EVENT_SCROLL_WHEEL && event != null -> { + consume(api, event) + } + + type == CG_EVENT_TAP_DISABLED_BY_TIMEOUT || + type == CG_EVENT_TAP_DISABLED_BY_USER_INPUT -> { + cancelCurrent() + eventTap.get()?.let { api.CGEventTapEnable(it, true) } + } + } + event + } + retainedCallback.set(callback) + val tap = + api.CGEventTapCreate(1, 0, 1, 1L shl CG_EVENT_SCROLL_WHEEL, callback, null) + ?: return@mapCatching false + val source = + api.CFMachPortCreateRunLoopSource(null, tap, 0) + ?: return@mapCatching false + eventTap.set(tap) + Thread({ runTapSafely(api, tap, source) }, "boss-scroll-phases").apply { + isDaemon = true + start() + } + true + }.getOrElse { error -> + logger.warn(LogCategory.BROWSER, "Native scroll phase observation unavailable", error = error) + false + } + started.set(available) + } + } + + // Event parsing, atomic transition, publication, and claimant delivery must stay ordered. + @Suppress("NestedBlockDepth") + private fun consume( + api: ApplicationServices, + event: Pointer, + ) { + try { + val phase = api.CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_SCROLL_PHASE) + val momentum = api.CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_MOMENTUM_PHASE) + val dx = + api + .CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_2) + .toDouble() + val dy = + api + .CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_1) + .toDouble() + while (true) { + val before = snapshot.get() + val (after, ended) = + scrollGestureTransition(before, phase, momentum, dx, dy, System.currentTimeMillis()) + if (!snapshot.compareAndSet(before, after)) continue + if (after != before && after.active) { + System.setProperty(PHASE_PROPERTY, "${after.id}:active:${after.beganAtEpochMs}") + } + if (ended != null) { + val terminal = if (ended.cancelled) "cancelled" else "ended" + val terminalEvidence = + "${ended.accumX}:${ended.verticalPath}:${ended.rejected}:${ended.reversed}" + System.setProperty( + PHASE_PROPERTY, + "${ended.id}:$terminal:$terminalEvidence", + ) + } + ended?.claimantIds?.forEach { claimantId -> + listeners[claimantId]?.let { listener -> runCatching { listener(ended) } } + } + return + } + } catch (error: VirtualMachineError) { + throw error + } catch (error: LinkageError) { + reportEventFailure(error) + } catch ( + @Suppress("TooGenericExceptionCaught") error: Exception, + ) { + reportEventFailure(error) + } + } + + private fun reportEventFailure(error: Throwable) { + if (failuresLogged.getAndIncrement() == 0L) { + logger.warn(LogCategory.BROWSER, "Native scroll phase event failed", error = error) + } + } + + private fun cancelCurrent() { + val before = snapshot.getAndUpdate { it.copy(active = false, observable = false) } + if (before.active) { + val end = + ScrollGestureEnd( + before.id, + true, + before.accumX, + before.verticalPath, + true, + before.reversed, + before.claimantIds, + ) + System.setProperty( + PHASE_PROPERTY, + "${end.id}:cancelled:${end.accumX}:${end.verticalPath}:${end.rejected}:${end.reversed}", + ) + end.claimantIds.forEach { claimantId -> + listeners[claimantId]?.let { listener -> runCatching { listener(end) } } + } + } else { + System.setProperty(PHASE_PROPERTY, "unavailable") + } + } + + private fun runTap( + api: ApplicationServices, + tap: Pointer, + source: Pointer, + ) { + val loop = api.CFRunLoopGetCurrent() + runLoop.set(loop) + // Run-loop mode constants are exported CFString singleton pointers. A newly allocated + // equal-looking string is not accepted here; CoreFoundation compares this sentinel by + // identity and would let CFRunLoopRun return immediately with the tap disabled. + val mode = + NativeLibrary + .getInstance("CoreFoundation") + .getGlobalVariableAddress("kCFRunLoopCommonModes") + .getPointer(0) + api.CFRunLoopAddSource(loop, source, mode) + api.CGEventTapEnable(tap, true) + api.CFRunLoopRun() + started.set(false) + System.setProperty(PHASE_PROPERTY, "unavailable") + logger.warn(LogCategory.BROWSER, "Native scroll phase run loop stopped unexpectedly") + } + + private fun runTapSafely( + api: ApplicationServices, + tap: Pointer, + source: Pointer, + ) { + try { + runTap(api, tap, source) + } catch (error: VirtualMachineError) { + throw error + } catch (error: LinkageError) { + reportRunLoopFailure(error) + } catch ( + @Suppress("TooGenericExceptionCaught") error: Exception, + ) { + reportRunLoopFailure(error) + } + } + + private fun reportRunLoopFailure(error: Throwable) { + started.set(false) + System.setProperty(PHASE_PROPERTY, "unavailable") + logger.warn(LogCategory.BROWSER, "Native scroll phase run loop failed", error = error) + } + + const val PHASE_PROPERTY = "boss.browser.swipe.phase" +} + +internal class ScrollGestureClaim internal constructor( + private val claimantId: Long, +) : AutoCloseable { + fun currentGestureToken(): String? = MacOSScrollGesturePhases.claimCurrent(claimantId) + + override fun close() = MacOSScrollGesturePhases.unregister(claimantId) +} + +private fun interface ScrollEventCallback : Callback { + fun invoke( + proxy: Pointer?, + type: Int, + event: Pointer?, + refcon: Pointer?, + ): Pointer? +} + +// These names are the native ApplicationServices symbols JNA binds verbatim. +@Suppress("FunctionNaming", "LongParameterList", "ktlint:standard:function-naming") +private interface ApplicationServices : Library { + fun CGPreflightListenEventAccess(): Boolean + + fun CGEventTapCreate( + location: Int, + placement: Int, + options: Int, + mask: Long, + callback: ScrollEventCallback, + refcon: Pointer?, + ): Pointer? + + fun CGEventGetIntegerValueField( + event: Pointer, + field: Int, + ): Long + + fun CGEventTapEnable( + tap: Pointer, + enable: Boolean, + ) + + fun CFMachPortCreateRunLoopSource( + allocator: Pointer?, + port: Pointer, + order: Long, + ): Pointer? + + fun CFRunLoopGetCurrent(): Pointer + + fun CFRunLoopAddSource( + loop: Pointer, + source: Pointer, + mode: Pointer, + ) + + fun CFRunLoopRun() +} + +private const val CG_EVENT_SCROLL_WHEEL = 22 +private const val CG_EVENT_TAP_DISABLED_BY_TIMEOUT = -2 +private const val CG_EVENT_TAP_DISABLED_BY_USER_INPUT = -1 +private const val CG_SCROLL_WHEEL_EVENT_SCROLL_PHASE = 99 +private const val CG_SCROLL_WHEEL_EVENT_MOMENTUM_PHASE = 123 +private const val CG_SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_1 = 96 +private const val CG_SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_2 = 97 +private const val CG_SCROLL_PHASE_NONE = 0L +private const val CG_SCROLL_PHASE_BEGAN = 1L +private const val CG_SCROLL_PHASE_CHANGED = 2L +private const val CG_SCROLL_PHASE_ENDED = 4L +private const val CG_SCROLL_PHASE_CANCELLED = 8L +private const val CG_SCROLL_PHASE_MAY_BEGIN = 128L diff --git a/composeApp/src/desktopMain/resources/browser/swipe-nav.js b/composeApp/src/desktopMain/resources/browser/swipe-nav.js index 84f477010c..775c53e202 100644 --- a/composeApp/src/desktopMain/resources/browser/swipe-nav.js +++ b/composeApp/src/desktopMain/resources/browser/swipe-nav.js @@ -2,7 +2,8 @@ // // Why in the page at all: on macOS the app runs Chromium in HARDWARE_ACCELERATED mode, // where the browser is a native surface layered over the window rather than a component -// in the Compose scene. Neither Compose nor AWT sees the wheel there, and Chromium's own +// in the Compose scene. Compose does not see the wheel there, and AWT discards macOS scroll +// phases before forwarding it; Chromium's own // overscroll history navigation does nothing for a trackpad in EITHER rendering mode - measured, // see the note at its call site in BrowserServiceImpl; it is a touchscreen feature. // The renderer, however, always sees the wheel - that is how pages scroll - so the page is @@ -26,9 +27,6 @@ // Horizontal travel that commits the navigation, in CSS pixels. var COMMIT_PX = 90; - // No wheel event for this long ends the gesture. AWT does not surface NSEvent's scroll - // phases, so a time gap is the only segmentation signal available. - var GESTURE_GAP_MS = 120; // A trackpad emits a stream of small fractional deltas; a mouse wheel emits a few big // discrete ones. At the AWT layer a shift-modified mouse wheel is byte-identical to a // horizontal trackpad swipe, so these two bounds are the only thing separating them. @@ -63,11 +61,17 @@ var accumX = 0; var verticalPath = 0; var eventCount = 0; - var lastEventAt = 0; + // The host's CoreGraphics observer assigns one id while fingers remain on the trackpad. + // It becomes inactive before momentum begins, so momentum wheel events cannot join this. + var nativeGestureId = null; + var endedGestureId = null; // Set once the gesture has been ruled out; stays set until the gesture ends, so a // rejected swipe cannot become an accepted one halfway through. var rejected = false; var direction = 0; + // The scroll chain under the first event owns the whole gesture. Keeping the initial path + // matters when a vertical first delta turns horizontal after the pointer has moved. + var scrollPath = null; // Latched the first time this gesture is past COMMIT_PX with enough events to be real. // // From that point the vertical tiers stop being asked. Vertical is a PATH LENGTH, so it only @@ -78,14 +82,6 @@ // easing back below COMMIT_PX, or reversing outright. That is also what a native swipe-back // does - once you are past the threshold, wobble no longer counts against you. var reachedCommit = false; - // Ends the gesture when the fingers lift. - // - // The gap check at the top of onWheel cannot do this on its own: it only runs when a NEXT - // event arrives, so a swipe abandoned halfway leaves the affordance parked in the page until - // the user happens to scroll again, which may be never. A timer is the only thing that - // observes a finger lifting, since AWT does not surface NSEvent's scroll phases. - var endTimer = 0; - function state() { return w.__bossSwipeNavState || null; } @@ -105,26 +101,28 @@ return !!s && s.enabled === false; } - // Whether anything in the pointer's scroll chain can still move horizontally the way - // this gesture is pushing it. That is the carousel/map/spreadsheet guard, and it is the - // same question Chromium asks before treating an overscroll as a navigation. + // Whether the pointer's scroll chain owns horizontal gestures. A nested carousel, map, or + // spreadsheet keeps the whole gesture even at its current edge: the compositor may have + // reached that edge before this passive listener observes scrollLeft, and turning that last + // event into navigation is a surprising result. Only the document viewport yields at an edge. // // Asked once, at the start of a gesture, and then latched. Re-asking per event would // start navigating the moment a horizontally scrolled element reached its end, in the // middle of a swipe the user aimed at that element. - function chainCanScroll(event, dir) { + function eventPath(event) { var path; try { path = typeof event.composedPath === 'function' ? event.composedPath() : null; } catch (e) { path = null; } - if (!path) { + if (!path || typeof path.length !== 'number' || path.length === 0) { path = []; var node = event.target; while (node) { path.push(node); - node = node.parentNode; + // parentNode reaches a ShadowRoot; host gets from that root back into the page. + node = node.parentNode || node.host || null; } } var doc = w.document; @@ -132,6 +130,12 @@ if (scroller && path.indexOf(scroller) === -1) { path = path.concat([scroller]); } + return path; + } + + function chainCanScroll(path, dir) { + var doc = w.document; + var scroller = doc.scrollingElement || doc.documentElement; for (var i = 0; i < path.length; i++) { var el = path[i]; if (!el || el.nodeType !== 1) { @@ -142,8 +146,13 @@ continue; } var overflowX = ''; + var cssDirection = 'ltr'; + var overscrollX = 'auto'; try { - overflowX = w.getComputedStyle(el).overflowX; + var style = w.getComputedStyle(el); + overflowX = style.overflowX; + cssDirection = style.direction || 'ltr'; + overscrollX = style.overscrollBehaviorX || 'auto'; } catch (e2) { overflowX = ''; } @@ -160,8 +169,19 @@ if (!scrollable) { continue; } + if (el !== scroller) { + return true; + } var left = el.scrollLeft; - if (dir < 0 ? left > 1 : left < range - 1) { + // Chromium uses the negative scrollLeft model for RTL: zero is the right edge and + // -range is the left edge. Wheel delta direction stays physical, so its edge checks + // are the inverse of LTR's positive coordinate range. + var canMove = cssDirection === 'rtl' + ? (dir < 0 ? left > -range + 1 : left < -1) + : (dir < 0 ? left > 1 : left < range - 1); + // A root that explicitly contains overscroll also refuses history navigation at its + // boundary, matching the page author's scroll-chain policy. + if (canMove || overscrollX === 'contain' || overscrollX === 'none') { return true; } } @@ -173,9 +193,7 @@ // ONE overlay for the life of the document, shown and hidden rather than built and destroyed. // // It used to be created per gesture and removed on a timer after the exit animation, which - // stacked: the exit runs EXIT_MS and a gesture ends after GESTURE_GAP_MS of quiet - 180 against - // 120 - so swiping twice in a row - // - not a corner case, just swiping twice - left two chevrons on screen at once. A single + // stacked when a second swipe began during that animation. A single // element cannot stack however the gestures overlap. var host = null; var root = null; @@ -287,27 +305,17 @@ // End of gesture: everything goes back to neutral. Never to abandon a gesture in flight - // clearing `rejected` mid-gesture would let a swipe this code already ruled out come back. // - // Both signals that a gesture ended call decide() immediately before this: the endTimer - // callback, and the gap check at the top of onWheel (a NEXT event arriving after - // GESTURE_GAP_MS of quiet). Whichever of the two gets there first, the other's decide() is a - // no-op, because this already put `direction` and `accumX` back to zero. Neither may be the - // only decider: Chromium dispatches input on a higher-priority task queue than timers, so a - // wheel event can run ahead of an already-due timer task, and a completed gesture that got - // reset without deciding is a navigation the user made and silently did not get. - // - // pagehide is the one reset() that deliberately does NOT decide - see decide(). + // A matching native release calls decide() first. Lifecycle resets deliberately do not. function reset() { - if (endTimer) { - w.clearTimeout(endTimer); - endTimer = 0; - } hideAffordance(direction); accumX = 0; verticalPath = 0; eventCount = 0; rejected = false; direction = 0; + scrollPath = null; reachedCommit = false; + nativeGestureId = null; } // Rule the current gesture out, keeping the accumulators so nothing restarts until the @@ -354,21 +362,11 @@ // and together clear COMMIT_PX before a third event ever arrives. Without this, that pair // navigates with no chevron ever drawn. // - available(direction): asked again here rather than reusing the answer latched in onWheel, - // because the host can push new state during the gesture and throughout the GESTURE_GAP_MS - // after it. It subsumes switchedOff() - the setting can flip mid-gesture, and the comment on + // because the host can push new state during the gesture. It subsumes switchedOff() - the + // setting can flip mid-gesture, and the comment on // switchedOff() promises the detector stops when it does - and additionally fails closed if // the direction lost its history entry, or if state went away entirely. // - // MOMENTUM costs latency here and nothing else, which is what reachedCommit is for. The - // numbers are in AGENTS.md: macOS emits momentum-phase scroll for 180-870ms after the fingers - // lift, carrying 325-2500px, measured on this hardware. Whether Chromium forwards those to the - // renderer as `wheel` events is not confirmed and cannot be settled with synthetic events. If - // it does, each one re-arms endTimer through onWheel and a flick commits at end-of-momentum - // rather than at release. - // - // It cannot change the ANSWER, only when it arrives: a momentum tail runs the flick's own - // direction, so it cannot reverse or ease back, and past COMMIT_PX the vertical tiers - the - // one path by which a tail's dy could have cancelled a completed swipe - are no longer asked. function decide() { if (rejected || direction === 0 || eventCount < MIN_EVENTS || !available(direction)) { return; @@ -379,33 +377,50 @@ } function onWheel(event) { - var now = Date.now(); - if (now - lastEventAt > GESTURE_GAP_MS) { - // This much quiet means the PREVIOUS gesture ended, and this event belongs to a new - // one - so the old gesture gets decided here, not silently discarded. See reset(). - decide(); - reset(); + var bridge = w.__bossSwipeNav; + var activeId = null; + try { + activeId = bridge && typeof bridge.activeGestureId === 'function' + ? bridge.activeGestureId() + : null; + } catch (e) { + activeId = null; } - lastEventAt = now; - // Kept armed for as long as events keep arriving; fires once they stop - the other of the - // two places a gesture is decided and then reset to neutral either way. - // - // Armed before the early returns below on purpose: a gesture that is rejected or switched - // off partway still has to end, and this timer is the only thing that ends one. The cost - // is a clearTimeout/setTimeout pair on every wheel event, even with the gesture switched - // off, on the hottest path in a browser. - if (endTimer) { - w.clearTimeout(endTimer); + // No active finger sequence means this is momentum, a mouse wheel, or native observation + // is unavailable. All fail closed. A new id cannot finish accumulators from an older one. + if (activeId === null || activeId === undefined) { + return; } - endTimer = w.setTimeout(function () { - endTimer = 0; - decide(); + var tokenParts = String(activeId).split(':'); + activeId = tokenParts[0]; + var beganAt = Number(tokenParts[1]); + // performance.timeOrigin + event.timeStamp is epoch time in Chromium. Reject anything + // observably older than this native sequence; JxBrowser does not preserve the original + // AWT timestamp, so this is deliberately only an extra conservative check. + if (beganAt && w.performance && + w.performance.timeOrigin + event.timeStamp + 1 < beganAt) { + return; + } + if (nativeGestureId !== null && nativeGestureId !== activeId) { reset(); - }, GESTURE_GAP_MS); + } + if (endedGestureId === activeId) { + return; + } + nativeGestureId = activeId; if (rejected || switchedOff()) { return; } + // Run in bubble phase so a page widget gets the first chance to claim a synthetic or + // JavaScript-driven horizontal scroller with preventDefault(). + if (event.defaultPrevented) { + abandon(); + return; + } + if (!scrollPath) { + scrollPath = eventPath(event); + } // Line and page modes come from sources that are never a trackpad. if (event.deltaMode !== 0) { abandon(); @@ -448,7 +463,7 @@ direction = dir; // Decided once per gesture and latched, both of these: which way it goes, and // whether the page wanted the scroll for itself. - if (chainCanScroll(event, dir) || !available(dir)) { + if (chainCanScroll(scrollPath, dir) || !available(dir)) { abandon(); return; } @@ -478,12 +493,32 @@ trackAffordance(direction, progress); } - // Capture-phase and passive: the gesture only commits when nothing was going to scroll, - // so there is never anything to preventDefault, and passive keeps this off Chromium's - // scroll-blocking path. - w.addEventListener('wheel', onWheel, { capture: true, passive: true }); + // Called by the host only for a native Ended/Cancelled phase with no momentum phase. + w.__bossSwipeNavRelease = function (gestureId, cancelled, nativeX, nativeRejected) { + endedGestureId = String(gestureId); + if (nativeGestureId === null || String(gestureId) !== nativeGestureId) { + return; + } + // CoreGraphics is authoritative at release so an earlier native Changed event delayed on + // Chromium's renderer queue cannot make a late easing-back or reversal look committed. + if (!cancelled && !nativeRejected && Math.abs(nativeX) >= COMMIT_PX) { + // Native and DOM wheel signs are allowed to differ; reversal is evaluated wholly in + // the native coordinate system and the page's latched direction chooses navigation. + accumX = direction * Math.abs(nativeX); + decide(); + } + reset(); + }; + + // Bubble phase lets target/page handlers claim custom scrollers with preventDefault first; + // passive keeps this observer off Chromium's scroll-blocking path. + w.addEventListener('wheel', onWheel, { capture: false, passive: true }); // pagehide is NOT routed through decide() - the page is already unloading, so navigating // it anywhere is moot at best; this only tears down the affordance so nothing outlives the // document it was drawn into. w.addEventListener('pagehide', reset, { capture: true }); + w.addEventListener('blur', reset, { capture: true }); + w.addEventListener('visibilitychange', function () { + if (w.document.hidden) reset(); + }, { capture: true }); })(); diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavTest.kt index 21b0de55cf..08e2b80e78 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavTest.kt @@ -128,15 +128,9 @@ class BrowserSwipeNavTest { ) } - /** - * The paused-drag case, and the reason [SWIPE_NAV_REPEAT_MS] exists: `swipe-nav.js` segments - * gestures on `GESTURE_GAP_MS` of quiet, which a drag that hesitates mid-swipe produces with - * the fingers still down. `scripts/test/test-swipe-nav.js` pins that the script really does - * emit two commits there - it has no way not to - so this is where the second one dies. - */ @Test fun `a same-direction repeat inside the repeat window is refused`() { - assertFalse(shouldAcceptSwipeNav(1_000 + GESTURE_GAP_MS_FLOOR, back(1_000), SwipeNavDirection.BACK)) + assertFalse(shouldAcceptSwipeNav(1_000 + SWIPE_NAV_DEBOUNCE_MS + 1, back(1_000), SwipeNavDirection.BACK)) assertFalse(shouldAcceptSwipeNav(1_000 + SWIPE_NAV_REPEAT_MS, back(1_000), SwipeNavDirection.BACK)) } @@ -145,39 +139,15 @@ class BrowserSwipeNavTest { assertTrue(shouldAcceptSwipeNav(1_001 + SWIPE_NAV_REPEAT_MS, back(1_000), SwipeNavDirection.BACK)) } - /** - * Both windows are bounded against `swipe-nav.js`'s own `GESTURE_GAP_MS`, read OUT of the - * script rather than restated here. The whole argument is a cross-language coupling to a - * constant in another file: hard-coded, changing the gap would leave this green while quietly - * falsifying its own reasoning. - * - * The debounce sits strictly below the gap - that is the minimum possible distance between two - * gesture ends, so anything at or above it starts refusing real swipes - and at or above one - * frame, which is the shape a bridge-level double-dispatch takes. The repeat window sits - * strictly above it, because a hesitation the script mistook for a lift is by definition longer - * than the gap. - */ @Test - fun `both windows are bounded against the script's gesture gap`() { - val match = GESTURE_GAP_MS_IN_SCRIPT.find(BrowserSwipeNavScript.source) - assertNotNull(match, "GESTURE_GAP_MS not found in swipe-nav.js") - val gapMs = match.groupValues[1].toLong() + fun `the debounce covers a frame and stays below the repeat guard`() { assertTrue( - SWIPE_NAV_DEBOUNCE_MS < gapMs, - "$SWIPE_NAV_DEBOUNCE_MS must stay under the script's ${gapMs}ms gesture gap, or it can " + - "reject a genuinely separate swipe", + SWIPE_NAV_DEBOUNCE_MS < SWIPE_NAV_REPEAT_MS, ) assertTrue( SWIPE_NAV_DEBOUNCE_MS >= ONE_FRAME_MS, "$SWIPE_NAV_DEBOUNCE_MS must still cover a same-frame double-dispatch", ) - assertTrue( - SWIPE_NAV_REPEAT_MS > gapMs, - "$SWIPE_NAV_REPEAT_MS must exceed the script's ${gapMs}ms gesture gap, or it guards " + - "nothing the script did not already merge", - ) - // The value the other tests use for "one gesture gap later" has to be the real one. - assertEquals(gapMs, GESTURE_GAP_MS_FLOOR) } // --- The gate that holds that state ------------------------------------------------------ @@ -187,7 +157,7 @@ class BrowserSwipeNavTest { var now = 1_000L val gate = SwipeNavGate(nowMs = { now }) assertTrue(gate.accept(SwipeNavDirection.BACK)) - now += GESTURE_GAP_MS_FLOOR + now += SWIPE_NAV_DEBOUNCE_MS + 1 assertFalse(gate.accept(SwipeNavDirection.BACK), "the second half of a paused drag") now += SWIPE_NAV_REPEAT_MS assertTrue(gate.accept(SwipeNavDirection.BACK), "a deliberate second swipe later on") @@ -265,16 +235,8 @@ class BrowserSwipeNavTest { } private companion object { - // Anchored to the start of a line and tolerant of spacing, so a commented-out declaration - // is not what gets read. It still fails loudly rather than wrongly if the shape changes: - // no match is an assertion failure, not a silently skipped bound. - val GESTURE_GAP_MS_IN_SCRIPT = Regex("""^\s*var GESTURE_GAP_MS\s*=\s*(\d+)""", RegexOption.MULTILINE) const val ONE_FRAME_MS = 16L - // Kept honest against the script by `both windows are bounded against the script's gesture - // gap`, so the cases below can say "one gesture gap later" without re-parsing the source. - const val GESTURE_GAP_MS_FLOOR = 120L - fun back(atMs: Long) = SwipeNavCommit(atMs, SwipeNavDirection.BACK) } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhasesTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhasesTest.kt new file mode 100644 index 0000000000..260139aed6 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhasesTest.kt @@ -0,0 +1,93 @@ +package ai.rever.boss.plugin.browser + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MacOSScrollGesturePhasesTest { + @Test + fun `sdk phase values begin change and end one gesture`() { + var state = ScrollGestureSnapshot(0, false) + state = scrollGestureTransition(state, phase = 128, momentumPhase = 0).first + val afterBegan = scrollGestureTransition(state, phase = 1, momentumPhase = 0).first + assertEquals(1, afterBegan.id, "MayBegin followed by Began must retain one id") + state = scrollGestureTransition(afterBegan, phase = 2, momentumPhase = 0, dx = -100.0).first + val (ended, signal) = scrollGestureTransition(state, phase = 4, momentumPhase = 0) + assertFalse(ended.active) + assertEquals(ScrollGestureEnd(1, false, -100.0, 0.0, false, false, emptySet()), signal) + } + + @Test + fun `sdk cancelled value resets without looking like release`() { + val active = scrollGestureTransition(ScrollGestureSnapshot(4, false), phase = 1, momentumPhase = 0).first + val (_, signal) = scrollGestureTransition(active, phase = 8, momentumPhase = 0) + assertEquals(true, signal?.cancelled) + } + + @Test + fun `changed cannot resurrect an inactive gesture and momentum is unobservable`() { + val idle = ScrollGestureSnapshot(7, false) + val changed = scrollGestureTransition(idle, phase = 2, momentumPhase = 0) + assertEquals(idle, changed.first) + assertNull(changed.second) + + val active = scrollGestureTransition(idle, phase = 1, momentumPhase = 0).first + val momentum = scrollGestureTransition(active, phase = 0, momentumPhase = 1).first + assertFalse(momentum.active) + assertFalse(momentum.observable) + assertEquals(true, scrollGestureTransition(active, phase = 0, momentumPhase = 1).second?.cancelled) + } + + @Test + fun `ended wins when momentum begins on the same event`() { + val active = scrollGestureTransition(ScrollGestureSnapshot(0, false), phase = 1, momentumPhase = 0).first + val (ended, signal) = scrollGestureTransition(active, phase = 4, momentumPhase = 1, dx = -500.0) + assertEquals(false, signal?.cancelled) + assertEquals(0.0, signal?.accumX, "momentum on the terminal event is not finger travel") + assertFalse(ended.observable, "a new document must not claim the ended id") + } + + @Test + fun `cancelled outranks ended when both terminal bits appear`() { + val active = scrollGestureTransition(ScrollGestureSnapshot(0, false), phase = 1, momentumPhase = 0).first + assertEquals(true, scrollGestureTransition(active, phase = 4 or 8, momentumPhase = 0).second?.cancelled) + } + + @Test + fun `native final displacement records easing and reversal`() { + var state = scrollGestureTransition(ScrollGestureSnapshot(0, false), 1, 0, -110.0, 0.0).first + state = scrollGestureTransition(state, 2, 0, 30.0, 0.0).first + assertEquals(-80.0, state.accumX) + assertFalse(state.rejected, "easing without crossing through zero stays cancellable at release") + state = scrollGestureTransition(state, 2, 0, 100.0, 0.0).first + assertTrue(state.rejected, "crossing through zero is a reversal") + } + + @Test + fun `multiple browser claimants cannot starve one another`() { + val active = scrollGestureTransition(ScrollGestureSnapshot(0, false), phase = 1, momentumPhase = 0).first + val first = claimScrollGesture(active, claimantId = 10) + assertEquals(setOf(10L), first?.claimantIds) + val both = claimScrollGesture(first!!, claimantId = 20) + assertEquals(setOf(10L, 20L), both?.claimantIds) + assertEquals(both, claimScrollGesture(both!!, claimantId = 10)) + } + + @Test + fun `end is delivered to its claimant and no claimant means no target`() { + val active = scrollGestureTransition(ScrollGestureSnapshot(0, false), phase = 1, momentumPhase = 0).first + assertEquals(emptySet(), scrollGestureTransition(active, phase = 4, momentumPhase = 0).second?.claimantIds) + + val claimed = claimScrollGesture(active, claimantId = 17)!! + assertEquals(setOf(17L), scrollGestureTransition(claimed, phase = 4, momentumPhase = 0).second?.claimantIds) + } + + @Test + fun `a claim racing after native end fails closed`() { + val active = scrollGestureTransition(ScrollGestureSnapshot(0, false), phase = 1, momentumPhase = 0).first + val ended = scrollGestureTransition(active, phase = 4, momentumPhase = 0).first + assertNull(claimScrollGesture(ended, claimantId = 4)) + } +} diff --git a/scripts/test/test-swipe-nav.js b/scripts/test/test-swipe-nav.js index b7cab1ef1b..60f01cdd2a 100644 --- a/scripts/test/test-swipe-nav.js +++ b/scripts/test/test-swipe-nav.js @@ -61,7 +61,7 @@ function hostProperties() { if (!m) throw new Error(`${name} not found in BrowserSwipeNavScript.kt`); return m[1]; }; - return { bridge: grab('BRIDGE_PROPERTY'), state: grab('STATE_PROPERTY') }; + return { bridge: grab('BRIDGE_PROPERTY'), state: grab('STATE_PROPERTY'), release: grab('RELEASE_PROPERTY') }; } /** @@ -88,6 +88,9 @@ function newPage(js, options = {}) { const registrations = []; let clockMs = 1000; let preventedDefaults = 0; + let nativeGestureId = '1'; + let nativeX = 0; + let nativeBeganAt = 0; const element = (props = {}) => Object.assign( @@ -157,11 +160,16 @@ function newPage(js, options = {}) { }; const shadowRoots = []; sandbox.window = { + performance: { timeOrigin: 0 }, addEventListener: add, setTimeout: sandbox.setTimeout, clearTimeout: sandbox.clearTimeout, matchMedia: () => ({ matches: options.reduceMotion === true }), - getComputedStyle: (el) => ({ overflowX: el.overflowX }), + getComputedStyle: (el) => ({ + overflowX: el.overflowX, + direction: el.direction || 'ltr', + overscrollBehaviorX: el.overscrollBehaviorX || 'auto', + }), document: { body: options.noBody ? null : body, scrollingElement: scroller, @@ -196,6 +204,7 @@ function newPage(js, options = {}) { // The clock is recorded alongside the direction: the host's SWIPE_NAV_DEBOUNCE_MS rests on // "two commits are never closer than GESTURE_GAP_MS", and that claim lives in this script. sandbox.window[hostProps.bridge] = { + activeGestureId: () => nativeGestureId === null ? null : `${nativeGestureId}:${nativeBeganAt}`, navigate: (d) => { navigated.push(d); navigatedAt.push(clockMs); @@ -208,14 +217,17 @@ function newPage(js, options = {}) { vm.createContext(sandbox); vm.runInContext(js, sandbox); - const wheel = (dx, dy, over) => { - const chain = over ? [over, scroller] : [body, scroller]; + const wheel = (dx, dy, over, eventOptions = {}) => { + if (nativeGestureId !== null) nativeX += dx; + const chain = eventOptions.path || (over ? [over, scroller] : [body, scroller]); const event = { deltaMode: 0, deltaX: dx, deltaY: dy, - target: chain[0], - composedPath: () => chain, + timeStamp: eventOptions.timeStamp === undefined ? clockMs : eventOptions.timeStamp, + defaultPrevented: eventOptions.defaultPrevented === true, + target: eventOptions.target || chain[0], + composedPath: eventOptions.noComposedPath ? undefined : () => chain, preventDefault: () => { preventedDefaults++; }, @@ -231,10 +243,10 @@ function newPage(js, options = {}) { registrations, wheel, wheelRaw: (event) => (listeners.wheel || []).forEach((f) => f(event)), - swipe: (count, dx, dy, over, startMs) => { + swipe: (count, dx, dy, over, startMs, eventOptions) => { for (let i = 0; i < count; i++) { if (startMs !== undefined) clockMs = startMs + i; - wheel(dx, dy || 0, over); + wheel(dx, dy || 0, over, eventOptions); } }, advance: (ms) => { @@ -252,9 +264,33 @@ function newPage(js, options = {}) { setState: (value) => { sandbox.window[hostProps.state] = value; }, + release: () => { + const ended = nativeGestureId; + nativeGestureId = null; + sandbox.window[hostProps.release](ended, false, nativeX, false); + }, + cancel: () => { + const ended = nativeGestureId; + nativeGestureId = null; + sandbox.window[hostProps.release](ended, true, nativeX, true); + }, + momentumWheel: (dx, dy) => { + nativeGestureId = null; + wheel(dx, dy || 0); + }, + newGesture: () => { + nativeGestureId = String(Number(nativeGestureId || 1) + 1); + nativeX = 0; + nativeBeganAt = clockMs; + }, // Let every pending timer run, however far in the future. What a lifted finger and a // finished exit animation look like from the script's side. settle: () => { + if (nativeGestureId !== null) { + const ended = nativeGestureId; + nativeGestureId = null; + sandbox.window[hostProps.release](ended, false, nativeX, false); + } for (let guard = 0; guard < 100; guard++) { if (!timers.length) return; clockMs = Math.max(clockMs, Math.min(...timers.map((t) => t.at))); @@ -289,11 +325,10 @@ const REPEAT_MS = hostRepeatMs(); const js = fs.readFileSync(scriptJs, 'utf8'); const constant = (name) => Number(new RegExp(`var ${name} = (\\d+)`).exec(js)[1]); const COMMIT_PX = constant('COMMIT_PX'); -const GAP_MS = constant('GESTURE_GAP_MS'); const MIN_EVENTS = constant('MIN_EVENTS'); const MAX_STEP_PX = constant('MAX_STEP_PX'); console.log( - `detector: COMMIT_PX=${COMMIT_PX} GESTURE_GAP_MS=${GAP_MS} MIN_EVENTS=${MIN_EVENTS} ` + + `detector: COMMIT_PX=${COMMIT_PX} MIN_EVENTS=${MIN_EVENTS} ` + `MAX_STEP_PX=${MAX_STEP_PX}; host: ${hostProps.bridge} / ${hostProps.state}, ` + `SWIPE_NAV_REPEAT_MS=${REPEAT_MS}`, ); @@ -303,7 +338,7 @@ console.log('\nwiring'); const p = newPage(js); check('installs exactly one wheel listener', p.installed() === 1, p.installed()); const wheelReg = p.registrations.find((r) => r.type === 'wheel'); - eq('listens capture-phase and passive', wheelReg.opts, { capture: true, passive: true }); + eq('listens in bubble phase and passive', wheelReg.opts, { capture: false, passive: true }); check( 'script names the host bridge property', js.includes(`window.${hostProps.bridge}`) || js.includes(`w.${hostProps.bridge}`), @@ -340,47 +375,43 @@ console.log('\na real swipe'); { const p = newPage(js); p.swipe(12, -10); - p.advance(GAP_MS + 80); + p.release(); + p.newGesture(); p.swipe(12, -10); p.settle(); - eq('two swipes across a gap navigate twice', p.navigated, ['back', 'back']); + eq('two native gesture ids navigate twice', p.navigated, ['back', 'back']); } { - // The floor the host's SWIPE_NAV_DEBOUNCE_MS is derived from, proved rather than asserted in - // prose: whatever the ordering of timers and events, two commits are never closer together than - // GESTURE_GAP_MS, because that gap IS how one gesture is told from the next. If a future change - // to decide()'s call sites falsifies that, the debounce's whole justification goes with it. + // A pause with fingers held produces no native Ended phase, however long it lasts. const p = newPage(js); - p.swipe(12, -10); - p.advance(GAP_MS + 40); - p.swipe(12, -10); - p.advance(GAP_MS + 40); - p.swipe(12, 10); + p.swipe(9, -10); + p.advance(REPEAT_MS + 1000); + eq('stationary hold does not commit', p.navigated, []); + p.swipe(3, 10); p.settle(); - eq('three gestures navigate three times', p.navigated, ['back', 'back', 'forward']); - check( - 'and no two commits are closer than the gesture gap', - minGap(p.navigatedAt) >= GAP_MS, - `${JSON.stringify(p.navigatedAt)} min gap ${minGap(p.navigatedAt)}, want >= ${GAP_MS}`, - ); + eq('and remains cancellable after the hold', p.navigated, []); } { - // A slow deliberate drag that HESITATES mid-swipe. 120ms of quiet with the fingers still down is - // byte-identical to a lift, so this is two gestures here and there is no signal that would make - // it one. What is pinned is that the script really does emit two commits: the guard against it - // is SWIPE_NAV_REPEAT_MS in BrowserSwipeNavBridge.kt, which has to live host-side because this - // script's state dies with the document the first commit navigates away from. const p = newPage(js); - p.swipe(9, -10); - p.advance(GAP_MS + 1); - p.swipe(9, -10); + p.swipe(2, -10); + p.release(); + p.advance(50); + p.newGesture(); + p.swipe(12, -10, 0, undefined, undefined, { timeStamp: 1000 }); p.settle(); - eq('a drag paused past the gesture gap is two gestures to the script', p.navigated, ['back', 'back']); - check( - 'and the two are close enough for the host repeat window to catch', - minGap(p.navigatedAt) <= REPEAT_MS, - `${JSON.stringify(p.navigatedAt)} min gap ${minGap(p.navigatedAt)}, want <= ${REPEAT_MS}`, - ); + eq('a renderer wheel older than the native begin cannot join the new id', p.navigated, []); +} +{ + const p = newPage(js); + p.swipe(12, -10); + p.cancel(); + eq('a native cancelled phase never navigates', p.navigated, []); +} +{ + const p = newPage(js); + p.swipe(10, -10); + p.momentumWheel(-1000, 200); + eq('momentum without an active finger id does not commit', p.navigated, []); } console.log('\nthe end of the gesture, not the threshold, is what commits'); @@ -468,7 +499,8 @@ console.log('\nthe end of the gesture, not the threshold, is what commits'); // gesture the user completed past the commit distance navigates nowhere, silently. const p = newPage(js); p.swipe(12, -10); - p.jump(GAP_MS + 1); + p.release(); + p.newGesture(); p.wheel(-10, 0); eq('a finished gesture still commits when the next event beats its timer', p.navigated, ['back']); p.settle(); @@ -554,7 +586,97 @@ console.log('\ngestures that must not navigate'); const carousel = p.element({ scrollWidth: 1200, clientWidth: 400, scrollLeft: 0, overflowX: 'auto' }); p.swipe(12, -10, 0, carousel); p.settle(); - eq('but does navigate once that element is at its edge', p.navigated, ['back']); + eq('a nested scroller retains the gesture at its left edge', p.navigated, []); +} +{ + const p = newPage(js); + const carousel = p.element({ scrollWidth: 1200, clientWidth: 400, scrollLeft: 800, overflowX: 'auto' }); + p.swipe(12, 10, 0, carousel); + p.settle(); + eq('a nested scroller retains the gesture at its right edge', p.navigated, []); +} +{ + const p = newPage(js); + // Chromium's RTL scrollLeft range runs from zero at the right edge to -range at the left. + const carousel = p.element({ + scrollWidth: 1200, + clientWidth: 400, + scrollLeft: -300, + overflowX: 'auto', + direction: 'rtl', + }); + p.swipe(12, -10, 0, carousel); + p.settle(); + eq('an RTL scroller keeps a gesture toward its physical left', p.navigated, []); +} +{ + const p = newPage(js); + const carousel = p.element({ + scrollWidth: 1200, + clientWidth: 400, + scrollLeft: 0, + overflowX: 'auto', + direction: 'rtl', + }); + p.swipe(12, 10, 0, carousel); + p.settle(); + eq('an RTL scroller retains the gesture at its right edge', p.navigated, []); +} +{ + const p = newPage(js); + const carousel = p.element({ + scrollWidth: 1200, + clientWidth: 400, + scrollLeft: -800, + overflowX: 'auto', + direction: 'rtl', + }); + p.swipe(12, -10, 0, carousel); + p.settle(); + eq('an RTL scroller retains the gesture at its left edge', p.navigated, []); +} +{ + const p = newPage(js); + const carousel = p.element({ scrollWidth: 1200, clientWidth: 400, scrollLeft: 20, overflowX: 'auto' }); + p.wheel(-10, 0, carousel); + carousel.scrollLeft = 0; + p.swipe(11, -10, 0, carousel); + p.settle(); + eq('a nested scroller reaching its edge mid-gesture never becomes navigation', p.navigated, []); +} +{ + const p = newPage(js); + const elsewhere = p.element({ scrollWidth: 1200, clientWidth: 400, scrollLeft: 300, overflowX: 'auto' }); + p.body.appendChild(elsewhere); + p.swipe(12, -10); + p.settle(); + eq('a scrollable element elsewhere on the page does not take the gesture', p.navigated, ['back']); +} +{ + const p = newPage(js); + const carousel = p.element({ scrollWidth: 1200, clientWidth: 400, scrollLeft: 300, overflowX: 'auto' }); + // The first event can be vertical or even zero-horizontal; ownership still belongs to the + // element where the gesture began when later events arrive over another target. + p.wheel(0, 0, carousel); + p.swipe(12, -10); + p.settle(); + eq('the initial event scroll chain owns the whole gesture', p.navigated, []); +} +{ + const p = newPage(js); + const host = p.element({ scrollWidth: 1200, clientWidth: 400, scrollLeft: 300, overflowX: 'auto' }); + const shadowRoot = { nodeType: 11, parentNode: null, host }; + const shadowChild = p.element({ parentNode: shadowRoot }); + p.swipe(12, -10, 0, shadowChild, undefined, { noComposedPath: true, target: shadowChild }); + p.settle(); + eq('fallback path traversal crosses a shadow root through its host', p.navigated, []); +} +{ + const p = newPage(js); + p.swipe(12, -10, 0, undefined, undefined, { defaultPrevented: true }); + p.settle(); + eq('a page handler that prevented the wheel owns the gesture', p.navigated, []); + check('default-prevented input never draws navigation UI', p.liveOverlays() === 0, p.liveOverlays()); } { const p = newPage(js); @@ -702,6 +824,20 @@ console.log('\nthe root scroller'); p.settle(); eq('a root that can still scroll keeps the gesture', p.navigated, []); } +{ + const p = newPage(js, { + root: { + scrollWidth: 4000, + clientWidth: 800, + scrollLeft: 0, + overflowX: 'visible', + overscrollBehaviorX: 'contain', + }, + }); + p.swipe(12, -10); + p.settle(); + eq('a contained root keeps the gesture at its boundary', p.navigated, []); +} console.log('\na fast flick'); { @@ -742,7 +878,8 @@ console.log('\nthe affordance'); // twice in a row looks like. const p = newPage(js); p.swipe(5, -10); - p.advance(GAP_MS + 80); + p.release(); + p.newGesture(); p.swipe(5, -10); check('never stacks a second chevron', p.liveOverlays() === 1, p.liveOverlays()); } From 26bd48eba555ab01dc6367c8fa3b52d740fc0479 Mon Sep 17 00:00:00 2001 From: Shivanshu Date: Tue, 15 Sep 2026 01:20:57 +0530 Subject: [PATCH 11/49] Fix swipe cancellation, observer lifecycle and terminal delivery --- AGENTS.md | 21 +- .../settings/sections/FluckBrowserSettings.kt | 17 +- .../ai/rever/boss/config/SwipeNavSettings.kt | 4 + .../boss/plugin/browser/BrowserHandleImpl.kt | 6 +- .../plugin/browser/BrowserSwipeNavBridge.kt | 8 +- .../plugin/browser/BrowserSwipeNavScript.kt | 6 + .../browser/MacOSScrollGesturePhases.kt | 509 +++++++++++------- .../plugin/browser/SwipeNavAvailability.kt | 36 ++ .../resources/browser/swipe-nav.js | 11 +- .../rever/boss/config/SwipeNavSettingsTest.kt | 15 + .../plugin/browser/ScrollPhaseObserverTest.kt | 321 +++++++++++ .../boss/plugin/browser/SwipeNavParityTest.kt | 90 ++++ docs/review/pr650-followup.md | 52 ++ scripts/test/swipe-nav-cases.json | 11 + scripts/test/test-swipe-nav.js | 76 ++- 15 files changed, 947 insertions(+), 236 deletions(-) create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/SwipeNavAvailability.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ScrollPhaseObserverTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/SwipeNavParityTest.kt create mode 100644 docs/review/pr650-followup.md create mode 100644 scripts/test/swipe-nav-cases.json diff --git a/AGENTS.md b/AGENTS.md index c304aba844..ed0b83426f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -808,10 +808,25 @@ carry over as the same fractions of the commit distance. session event tap assigns each finger sequence an id and accumulates its final point deltas. The page latches that id while deciding scroll ownership; only the matching native release can decide. A stationary hold therefore remains cancellable indefinitely. Cancelled phases reset without -navigating, and momentum has no active finger id and is ignored. Native final displacement is -authoritative at release so renderer/native queue reordering cannot hide a late easing-back or +navigating, and momentum has no active finger id and is ignored. Native final displacement magnitude is +authoritative at release (the page chooses direction) so renderer/native queue reordering cannot hide a late easing-back or reversal. If Input Monitoring preflight fails the feature fails closed and Settings shows how to -grant access; this path never requests permission itself. +grant access; this path never requests permission itself. The observer is started off the UI thread +only while the effective setting is enabled. Disabling cancels claimants immediately and releases +the native source and tap within the bounded run-loop poll. Re-enabling retries permission/setup; +run-loop failures cancel the contact, clean up resources and report a distinct failure state. +Availability reads and browser registration do not start native observation. + +`boss.browser.swipe.phase` remains compatible: `id:active:beganAtEpochMs` or +`id:ended|cancelled:netX:verticalPath:pageRejected:reversed`. Active publication occurs only at +contact begin, not on every movement. `boss.browser.swipe.terminals` additionally retains the last +32 terminal records, separated by semicolons, published before the next active contact. Companion +fluck-browser#45 reconciles by contact ID both before handling a new wheel and in its watchdog. +Reads are non-destructive across surfaces; missing/evicted evidence cancels. These deltas are native +CoreGraphics point deltas, not a guarantee of CSS-pixel or Compose-unit equivalence. Physical +trackpad calibration still needs to cover browser zoom, slow drags and the separately tuned home +surface. `SwipeNavParityTest` runs shared sample fixtures through the native reducer and the actual +page script, including the host-generated release statement; it also pins cancellation constants. One cross-process ordering limit remains: JxBrowser drops the AWT event timestamp before building the renderer wheel event. The epoch check can reject an event Chromium timestamps before the new diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt index da41e8c7f2..ec2ce80068 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt @@ -19,6 +19,7 @@ import ai.rever.boss.html.HtmlFileSettingsManager import ai.rever.boss.plugin.browser.BrowserSettings import ai.rever.boss.plugin.browser.BrowserSettingsManager import ai.rever.boss.plugin.browser.MacOSScrollGesturePhases +import ai.rever.boss.plugin.browser.swipeNavSettingsDescription import ai.rever.boss.plugin.ui.BossAlertDialog import ai.rever.boss.terminal.ExistingSplitTargetMode import ai.rever.boss.terminal.TerminalLinkOpenMode @@ -100,7 +101,7 @@ fun FluckBrowserSettings() { // stale value after the other one changes it. val stored by SwipeNavSettingsManager.settings.collectAsState() val swipeEnabled = parseSwipeNavEnabled(envOverride) ?: stored.enabled - val phaseAvailable = MacOSScrollGesturePhases.isAvailable() + val phaseAvailability by MacOSScrollGesturePhases.availability.collectAsState() SettingsToggle( label = "Two-finger swipe navigation", checked = swipeEnabled, @@ -108,19 +109,7 @@ fun FluckBrowserSettings() { // Disabled rather than silently ignored: a user with the variable exported // would otherwise watch this control do nothing and conclude it is broken. enabled = !envOwned, - description = - envOverride - .takeIf { envOwned } - ?.let { "Set by ${SwipeNavSettingsManager.KEY}=$it in the environment" } - ?: ( - if (!phaseAvailable) { - "Unavailable: allow BOSS under System Settings > Privacy & Security > " + - "Input Monitoring, then restart BOSS." - } else { - null - } - ) - ?: "Swipe right with two fingers to go back, left to go forward.", + description = swipeNavSettingsDescription(envOverride, phaseAvailability), ) } } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/SwipeNavSettings.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/SwipeNavSettings.kt index f9f4ba4410..62b9a4e421 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/SwipeNavSettings.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/SwipeNavSettings.kt @@ -103,6 +103,9 @@ object SwipeNavSettingsManager { parseSwipeNavEnabled(envOverride()) ?: _settings.value.enabled + /** Installed by application startup; settings-only consumers never create native resources. */ + internal var onEnabledChanged: ((Boolean) -> Unit)? = null + fun set(enabled: Boolean) { _settings.value = SwipeNavSettings(enabled) persist(_settings.value) @@ -111,6 +114,7 @@ object SwipeNavSettingsManager { /** Publish for the plugin half. Skipped when the environment owns the key, as elsewhere. */ fun publish() { + onEnabledChanged?.invoke(isEnabled()) if (envDecides()) { logger.info(LogCategory.BROWSER, "Swipe gesture setting ignored; the environment owns $KEY") return diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt index 1e6198f367..4286a5da4a 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt @@ -2040,7 +2040,7 @@ internal class BrowserHandleImpl( * renderer it will not block, and `goBack()` is a round trip into the browser. */ private fun onSwipeNavigate(direction: SwipeNavDirection) { - if (!isValid || !swipeNavGate.accept(direction)) return + if (!isValid || !BrowserSwipeNavScript.isEnabled() || !swipeNavGate.accept(direction)) return pageInjectScope.launch(pageInjectDispatcher) { when (direction) { SwipeNavDirection.BACK -> goBack() @@ -2053,9 +2053,7 @@ internal class BrowserHandleImpl( private fun onSwipeGestureEnded(end: ScrollGestureEnd) { if (!isValid) return pageInjectScope.launch(pageInjectDispatcher) { - val releaseFunction = "window.${BrowserSwipeNavScript.RELEASE_PROPERTY}" - val releaseArguments = "'${end.id}', ${end.cancelled}, ${end.accumX}, ${end.rejected}" - val statement = "$releaseFunction && $releaseFunction($releaseArguments);" + val statement = BrowserSwipeNavScript.release(end) runCatching { browser.mainFrame().ifPresent { it.executeJavaScript(statement) } } } } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavBridge.kt index 9075aac4db..4d083af2f4 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavBridge.kt @@ -137,9 +137,9 @@ internal fun shouldAcceptSwipeNav( direction: SwipeNavDirection, ): Boolean { if (previous == null) return true - // The repeat window is the larger of the two - pinned in BrowserSwipeNavTest against the - // script's own gesture gap, which sits between them - so a same-direction swipe clearing it has - // cleared the debounce as well, and one window per case is the whole rule. + // The repeat window is the larger of the two, so a same-direction swipe clearing it has + // cleared the debounce as well. Native gesture IDs do not survive this bridge's navigate call; + // the gate also limits duplicate or page-originated calls after document replacement. val window = if (direction == previous.direction) SWIPE_NAV_REPEAT_MS else SWIPE_NAV_DEBOUNCE_MS return nowMs - previous.atMs > window } @@ -178,5 +178,5 @@ internal class SwipeNavGate( /** Any direction, for a double-dispatch bug. Two frames, well clear of the script's 120ms floor. */ internal const val SWIPE_NAV_DEBOUNCE_MS = 32L -/** Same direction, for a drag that hesitated past the script's gesture gap. */ +/** Same-direction calls across document replacement, which discards the page's gesture ID. */ internal const val SWIPE_NAV_REPEAT_MS = 400L diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt index 41b35d0de2..e5eec7ff4a 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt @@ -69,6 +69,12 @@ internal object BrowserSwipeNavScript { canGoForward: Boolean, ): String = "window.$STATE_PROPERTY = { enabled: $enabled, back: $canGoBack, forward: $canGoForward };" + /** Only typed native values cross this boundary; use the same statement in parity tests. */ + fun release(end: ScrollGestureEnd): String { + val function = "window.$RELEASE_PROPERTY" + return "$function && $function('${end.id}', ${end.cancelled}, ${end.accumX}, ${end.rejected});" + } + private fun loadResource(path: String): String = try { BrowserSwipeNavScript::class.java.getResourceAsStream(path)?.use { diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt index 0be35e3a7a..0d6c302a7f 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt @@ -1,5 +1,6 @@ package ai.rever.boss.plugin.browser +import ai.rever.boss.config.SwipeNavSettingsManager import ai.rever.boss.utils.SystemUtils import ai.rever.boss.utils.logging.BossLogger import ai.rever.boss.utils.logging.LogCategory @@ -8,14 +9,16 @@ import com.sun.jna.Library import com.sun.jna.Native import com.sun.jna.NativeLibrary import com.sun.jna.Pointer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import java.util.concurrent.atomic.AtomicLong -import java.util.concurrent.atomic.AtomicReference /** The part of a macOS scroll sequence which comes from fingers still touching the trackpad. */ internal data class ScrollGestureSnapshot( val id: Long, val active: Boolean, val observable: Boolean = active, + val horizontalEvents: Int = 0, val accumX: Double = 0.0, val verticalPath: Double = 0.0, val direction: Int = 0, @@ -43,12 +46,12 @@ internal fun claimScrollGesture( claimantId: Long, ): ScrollGestureSnapshot? = when { - !current.active -> null + !current.active || !current.observable -> null claimantId in current.claimantIds -> current else -> current.copy(claimantIds = current.claimantIds + claimantId) } -@Suppress("LongParameterList") // Mirrors the independent numeric fields carried by one CGEvent. +@Suppress("LongParameterList", "CyclomaticComplexMethod") // One branch per native phase, including replacement. internal fun scrollGestureTransition( current: ScrollGestureSnapshot, phase: Long, @@ -82,7 +85,7 @@ internal fun scrollGestureTransition( true, mayBegin = true, beganAtEpochMs = nowEpochMs, - ) to null + ) to finish(current, 0.0, 0.0, cancelled = true).second } phase and CG_SCROLL_PHASE_BEGAN != 0L -> { @@ -94,7 +97,7 @@ internal fun scrollGestureTransition( }, dx, dy, - ) to null + ) to if (current.mayBegin) null else finish(current, 0.0, 0.0, cancelled = true).second } phase and CG_SCROLL_PHASE_CHANGED != 0L -> { @@ -135,6 +138,9 @@ private fun accumulate( ): ScrollGestureSnapshot { val x = current.accumX + dx val vertical = current.verticalPath + kotlin.math.abs(dy) + // Match the page: retain leading vertical travel, but evaluate tiers only on horizontal samples. + if (dx == 0.0) return current.copy(verticalPath = vertical) + val events = current.horizontalEvents + 1 val direction = current.direction.takeIf { it != 0 } ?: if (x < 0) { -1 @@ -143,259 +149,344 @@ private fun accumulate( } else { 0 } - val reversed = direction != 0 && x != 0.0 && (if (x < 0) -1 else 1) != direction + val reversed = current.direction != 0 && (if (x < 0) -1 else 1) != current.direction val xDelta = kotlin.math.abs(x) val rejected = current.rejected || reversed || ( !current.reachedCommit && - (vertical > 2 * xDelta || (vertical * 1.3 > xDelta && vertical > 90 * .125) || vertical > 90 * 3) + ( + vertical > SWIPE_CANCEL_STRONG_RATIO * xDelta || + ( + vertical * SWIPE_CANCEL_MIXED_RATIO > xDelta && + vertical > SWIPE_COMMIT_PX * SWIPE_CANCEL_VERTICAL_LOW + ) || + vertical > SWIPE_COMMIT_PX * SWIPE_CANCEL_VERTICAL_HIGH + ) ) return current.copy( active = true, observable = true, accumX = x, + horizontalEvents = events, verticalPath = vertical, direction = direction, rejected = rejected, reversed = current.reversed || reversed, - reachedCommit = current.reachedCommit || (!rejected && xDelta >= 90), + reachedCommit = current.reachedCommit || (!rejected && events >= SWIPE_MIN_EVENTS && xDelta >= SWIPE_COMMIT_PX), ) } +internal const val SWIPE_COMMIT_PX = 90.0 +internal const val SWIPE_MIN_EVENTS = 3 +internal const val SWIPE_CANCEL_STRONG_RATIO = 2.0 +internal const val SWIPE_CANCEL_MIXED_RATIO = 1.3 +internal const val SWIPE_CANCEL_VERTICAL_LOW = 0.125 +internal const val SWIPE_CANCEL_VERTICAL_HIGH = 3.0 + +internal enum class ScrollPhaseAvailability { DISABLED, STARTING, AVAILABLE, PERMISSION_DENIED, FAILED } + +/** A session owns its native resources on the observation thread, including partial setup. */ +internal interface ScrollPhaseSession : AutoCloseable { + /** False means the run loop no longer has a live source. */ + fun poll(): Boolean +} + +internal class ScrollPhasePermissionDenied : IllegalStateException() + /** - * Observes real macOS scroll phases. The event tap is listen-only and is created only after the - * non-prompting preflight succeeds; Boss never requests Input Monitoring from this path. + * Serializes transitions, claims, terminal publication and delivery. Native setup/poll/cleanup + * runs off the UI thread. A generation fences callbacks from disabled or replaced sessions. + * Terminal history is bounded and non-destructive so multiple home surfaces can reconcile it. */ -// The native tap lifecycle and per-browser claimant registry intentionally share one atomic owner. @Suppress("TooManyFunctions") -internal object MacOSScrollGesturePhases { - private val logger = BossLogger.forComponent("MacOSScrollGesturePhases") - private val listeners = java.util.concurrent.ConcurrentHashMap Unit>() - private val nextClaimantId = AtomicLong(0) - private val snapshot = AtomicReference(ScrollGestureSnapshot(0, false)) - private val started = AtomicReference(null) - private val retainedCallback = AtomicReference(null) - private val eventTap = AtomicReference(null) - private val runLoop = AtomicReference(null) - private val failuresLogged = AtomicLong(0) +internal class ScrollPhaseObserver( + private val open: (onEvent: (Long, Long, Double, Double) -> Unit, onInterrupted: () -> Unit) -> ScrollPhaseSession, + private val launch: (() -> Unit) -> Unit, + private val publish: (String, String) -> Unit, + private val reportFailure: (Throwable) -> Unit, +) { + private val lock = Any() + private val listeners = mutableMapOf Unit>() + private val nextClaimantId = AtomicLong() + private var snapshot = ScrollGestureSnapshot(0, false) + private var generation = 0L + private var enabled = false + private val terminals = ArrayDeque() + private val mutableAvailability = MutableStateFlow(ScrollPhaseAvailability.DISABLED) + val availability = mutableAvailability.asStateFlow() init { - System.setProperty(PHASE_PROPERTY, "unavailable") - } - - fun ensureStarted() = startIfAvailable() - - fun register(onEnded: (ScrollGestureEnd) -> Unit): ScrollGestureClaim { - val claimantId = nextClaimantId.incrementAndGet() - listeners[claimantId] = onEnded - startIfAvailable() - return ScrollGestureClaim(claimantId) + publish(MacOSScrollGesturePhases.PHASE_PROPERTY, "unavailable") + publish(MacOSScrollGesturePhases.TERMINALS_PROPERTY, "") } - internal fun claimCurrent(claimantId: Long): String? { - while (true) { - val before = snapshot.get() - val claimed = claimScrollGesture(before, claimantId) ?: return null - if (claimed === before || snapshot.compareAndSet(before, claimed)) { - return "${claimed.id}:${claimed.beganAtEpochMs}" + fun setEnabled(value: Boolean) { + val sessionId = + synchronized(lock) { + if (enabled == value && (!value || availability.value != ScrollPhaseAvailability.FAILED)) return + enabled = value + generation++ + cancelCurrent() + publish(MacOSScrollGesturePhases.PHASE_PROPERTY, "unavailable") + mutableAvailability.value = + if (value) ScrollPhaseAvailability.STARTING else ScrollPhaseAvailability.DISABLED + generation } - } - } - - internal fun unregister(claimantId: Long) { - listeners.remove(claimantId) + if (value) launch { observe(sessionId) } } - /** True when release detection can run; false also means preflight declined without prompting. */ - fun isAvailable(): Boolean { - startIfAvailable() - return started.get() == true + fun register(onEnded: (ScrollGestureEnd) -> Unit): ScrollGestureClaim { + val id = nextClaimantId.incrementAndGet() + synchronized(lock) { listeners[id] = onEnded } + return ScrollGestureClaim({ claimCurrent(id) }, { + synchronized(lock) { listeners.remove(id) } + Unit + }) } - private fun startIfAvailable() { - if (!SystemUtils.isMacOS || started.get() != null) return - synchronized(this) { - if (started.get() != null) return - val available = - runCatching { Native.load("ApplicationServices", ApplicationServices::class.java) } - .mapCatching { api -> - if (!api.CGPreflightListenEventAccess()) return@mapCatching false - val callback = - ScrollEventCallback { _, type, event, _ -> - when { - type == CG_EVENT_SCROLL_WHEEL && event != null -> { - consume(api, event) - } - - type == CG_EVENT_TAP_DISABLED_BY_TIMEOUT || - type == CG_EVENT_TAP_DISABLED_BY_USER_INPUT -> { - cancelCurrent() - eventTap.get()?.let { api.CGEventTapEnable(it, true) } - } - } - event - } - retainedCallback.set(callback) - val tap = - api.CGEventTapCreate(1, 0, 1, 1L shl CG_EVENT_SCROLL_WHEEL, callback, null) - ?: return@mapCatching false - val source = - api.CFMachPortCreateRunLoopSource(null, tap, 0) - ?: return@mapCatching false - eventTap.set(tap) - Thread({ runTapSafely(api, tap, source) }, "boss-scroll-phases").apply { - isDaemon = true - start() - } - true - }.getOrElse { error -> - logger.warn(LogCategory.BROWSER, "Native scroll phase observation unavailable", error = error) - false - } - started.set(available) + private fun claimCurrent(id: Long): String? = + synchronized(lock) { + if (!enabled || availability.value != ScrollPhaseAvailability.AVAILABLE) return null + val claimed = claimScrollGesture(snapshot, id) ?: return null + snapshot = claimed + "${claimed.id}:${claimed.beganAtEpochMs}" } - } - // Event parsing, atomic transition, publication, and claimant delivery must stay ordered. - @Suppress("NestedBlockDepth") - private fun consume( - api: ApplicationServices, - event: Pointer, - ) { + private fun isCurrent(id: Long): Boolean = synchronized(lock) { enabled && generation == id } + + @Suppress("TooGenericExceptionCaught") // Native setup and polling failures must cancel and release the session. + private fun observe(id: Long) { try { - val phase = api.CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_SCROLL_PHASE) - val momentum = api.CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_MOMENTUM_PHASE) - val dx = - api - .CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_2) - .toDouble() - val dy = - api - .CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_1) - .toDouble() - while (true) { - val before = snapshot.get() - val (after, ended) = - scrollGestureTransition(before, phase, momentum, dx, dy, System.currentTimeMillis()) - if (!snapshot.compareAndSet(before, after)) continue - if (after != before && after.active) { - System.setProperty(PHASE_PROPERTY, "${after.id}:active:${after.beganAtEpochMs}") - } - if (ended != null) { - val terminal = if (ended.cancelled) "cancelled" else "ended" - val terminalEvidence = - "${ended.accumX}:${ended.verticalPath}:${ended.rejected}:${ended.reversed}" - System.setProperty( - PHASE_PROPERTY, - "${ended.id}:$terminal:$terminalEvidence", - ) + if (!isCurrent(id)) return + open( + { phase, momentum, dx, dy -> consume(id, phase, momentum, dx, dy) }, + { synchronized(lock) { if (isCurrent(id)) cancelCurrent() } }, + ).use { session -> + synchronized(lock) { + if (!isCurrent(id)) return + mutableAvailability.value = ScrollPhaseAvailability.AVAILABLE } - ended?.claimantIds?.forEach { claimantId -> - listeners[claimantId]?.let { listener -> runCatching { listener(ended) } } + while (isCurrent(id)) { + check(session.poll()) { "Native scroll phase run loop stopped" } } - return } - } catch (error: VirtualMachineError) { - throw error + } catch (error: Exception) { + failed(id, error) } catch (error: LinkageError) { - reportEventFailure(error) - } catch ( - @Suppress("TooGenericExceptionCaught") error: Exception, - ) { - reportEventFailure(error) + failed(id, error) + } finally { + synchronized(lock) { + if (isCurrent(id)) { + cancelCurrent() + publish(MacOSScrollGesturePhases.PHASE_PROPERTY, "unavailable") + if (availability.value == ScrollPhaseAvailability.AVAILABLE) { + mutableAvailability.value = ScrollPhaseAvailability.FAILED + } + } + } } } - private fun reportEventFailure(error: Throwable) { - if (failuresLogged.getAndIncrement() == 0L) { - logger.warn(LogCategory.BROWSER, "Native scroll phase event failed", error = error) + private fun failed( + id: Long, + error: Throwable, + ): Unit = + synchronized(lock) { + if (!isCurrent(id)) return + cancelCurrent() + mutableAvailability.value = + if (error is ScrollPhasePermissionDenied) { + ScrollPhaseAvailability.PERMISSION_DENIED + } else { + reportFailure(error) + ScrollPhaseAvailability.FAILED + } } - } - private fun cancelCurrent() { - val before = snapshot.getAndUpdate { it.copy(active = false, observable = false) } - if (before.active) { - val end = - ScrollGestureEnd( - before.id, - true, - before.accumX, - before.verticalPath, - true, - before.reversed, - before.claimantIds, - ) - System.setProperty( - PHASE_PROPERTY, - "${end.id}:cancelled:${end.accumX}:${end.verticalPath}:${end.rejected}:${end.reversed}", - ) - end.claimantIds.forEach { claimantId -> - listeners[claimantId]?.let { listener -> runCatching { listener(end) } } + private fun consume( + id: Long, + phase: Long, + momentum: Long, + dx: Double, + dy: Double, + ): Unit = + synchronized(lock) { + if (!isCurrent(id)) return + val before = snapshot + val (after, end) = scrollGestureTransition(before, phase, momentum, dx, dy, System.currentTimeMillis()) + snapshot = after + // Publish the replaced contact's cancellation before announcing the new contact. + end?.let(::deliver) + if (after.active && (!before.active || after.id != before.id)) { + publish(MacOSScrollGesturePhases.PHASE_PROPERTY, "${after.id}:active:${after.beganAtEpochMs}") } - } else { - System.setProperty(PHASE_PROPERTY, "unavailable") } + + private fun cancelCurrent() { + val (after, end) = scrollGestureTransition(snapshot, CG_SCROLL_PHASE_CANCELLED, 0) + snapshot = after + end?.let(::deliver) } - private fun runTap( - api: ApplicationServices, - tap: Pointer, - source: Pointer, - ) { - val loop = api.CFRunLoopGetCurrent() - runLoop.set(loop) - // Run-loop mode constants are exported CFString singleton pointers. A newly allocated - // equal-looking string is not accepted here; CoreFoundation compares this sentinel by - // identity and would let CFRunLoopRun return immediately with the tap disabled. - val mode = - NativeLibrary - .getInstance("CoreFoundation") - .getGlobalVariableAddress("kCFRunLoopCommonModes") - .getPointer(0) - api.CFRunLoopAddSource(loop, source, mode) - api.CGEventTapEnable(tap, true) - api.CFRunLoopRun() - started.set(false) - System.setProperty(PHASE_PROPERTY, "unavailable") - logger.warn(LogCategory.BROWSER, "Native scroll phase run loop stopped unexpectedly") + private fun deliver(end: ScrollGestureEnd) { + val state = if (end.cancelled) "cancelled" else "ended" + val wire = "${end.id}:$state:${end.accumX}:${end.verticalPath}:${end.rejected}:${end.reversed}" + terminals.addLast(wire) + if (terminals.size > TERMINAL_HISTORY_LIMIT) terminals.removeFirst() + // History comes first: a reader observing the next active id can already recover the end. + publish(MacOSScrollGesturePhases.TERMINALS_PROPERTY, terminals.joinToString(";")) + publish(MacOSScrollGesturePhases.PHASE_PROPERTY, wire) + end.claimantIds.forEach { id -> listeners[id]?.let { runCatching { it(end) } } } } - private fun runTapSafely( - api: ApplicationServices, - tap: Pointer, - source: Pointer, - ) { + companion object { + const val TERMINAL_HISTORY_LIMIT = 32 + } +} + +/** Opens and releases every CoreFoundation resource on the same native observation thread. */ +// All acquired resources are released even if another cleanup fails. +@Suppress("NestedBlockDepth", "TooGenericExceptionCaught") +internal fun openScrollPhaseSession( + api: ApplicationServices, + mode: Pointer, + onEvent: (Long, Long, Double, Double) -> Unit, + onInterrupted: () -> Unit, +): ScrollPhaseSession { + if (!api.CGPreflightListenEventAccess()) throw ScrollPhasePermissionDenied() + var tap: Pointer? = null + var source: Pointer? = null + val loop = api.CFRunLoopGetCurrent() + var added = false + var callbackFailure: Throwable? = null + val callback = + ScrollEventCallback { _, type, event, _ -> + try { + when { + type == CG_EVENT_SCROLL_WHEEL && event != null -> { + onEvent( + api.CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_SCROLL_PHASE), + api.CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_MOMENTUM_PHASE), + api.CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_2).toDouble(), + api.CGEventGetIntegerValueField(event, CG_SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_1).toDouble(), + ) + } + + type == CG_EVENT_TAP_DISABLED_BY_TIMEOUT || type == CG_EVENT_TAP_DISABLED_BY_USER_INPUT -> { + onInterrupted() + tap?.let { api.CGEventTapEnable(it, true) } + } + } + } catch (error: Exception) { + callbackFailure = error + onInterrupted() + } catch (error: LinkageError) { + callbackFailure = error + onInterrupted() + } + event + } + + fun cleanup() { try { - runTap(api, tap, source) - } catch (error: VirtualMachineError) { - throw error - } catch (error: LinkageError) { - reportRunLoopFailure(error) - } catch ( - @Suppress("TooGenericExceptionCaught") error: Exception, - ) { - reportRunLoopFailure(error) + if (added) api.CFRunLoopRemoveSource(loop, checkNotNull(source), mode) + } finally { + try { + source?.let { api.CFRelease(it) } + } finally { + tap?.let { port -> + try { + api.CFMachPortInvalidate(port) + } finally { + api.CFRelease(port) + } + } + } } } + try { + tap = checkNotNull(api.CGEventTapCreate(1, 0, 1, 1L shl CG_EVENT_SCROLL_WHEEL, callback, null)) + source = checkNotNull(api.CFMachPortCreateRunLoopSource(null, tap, 0)) + api.CFRunLoopAddSource(loop, source, mode) + added = true + api.CGEventTapEnable(tap, true) + return object : ScrollPhaseSession { + // Retained until the tap is invalidated, including between native callbacks. + private val retainedCallback = callback + + override fun poll(): Boolean { + // A bounded wait avoids the stop-before-run race and lets an off switch tear down + // an idle tap without needing any further mouse/trackpad event. + callbackFailure?.let { throw it } + val result = api.CFRunLoopRunInMode(mode, 0.25, false) + callbackFailure?.let { throw it } + java.lang.ref.Reference + .reachabilityFence(retainedCallback) + return result != 1 && result != 2 + } - private fun reportRunLoopFailure(error: Throwable) { - started.set(false) - System.setProperty(PHASE_PROPERTY, "unavailable") - logger.warn(LogCategory.BROWSER, "Native scroll phase run loop failed", error = error) + override fun close() { + try { + cleanup() + } finally { + java.lang.ref.Reference + .reachabilityFence(retainedCallback) + } + } + } + } catch (error: Throwable) { + cleanup() + throw error } +} +internal object MacOSScrollGesturePhases { const val PHASE_PROPERTY = "boss.browser.swipe.phase" + const val TERMINALS_PROPERTY = "boss.browser.swipe.terminals" + private val logger = BossLogger.forComponent("MacOSScrollGesturePhases") + private val observer = + ScrollPhaseObserver( + open = { event, interrupted -> + val api = Native.load("ApplicationServices", ApplicationServices::class.java) + // RunInMode requires a concrete mode, not the common-modes registration sentinel. + val mode = + NativeLibrary + .getInstance("CoreFoundation") + .getGlobalVariableAddress("kCFRunLoopDefaultMode") + .getPointer(0) + openScrollPhaseSession(api, mode, event, interrupted) + }, + launch = { task -> + Thread(task, "boss-scroll-phases").apply { + isDaemon = true + start() + } + }, + publish = { key, value -> System.setProperty(key, value) }, + reportFailure = { logger.warn(LogCategory.BROWSER, "Native scroll phase observation failed", error = it) }, + ) + val availability = observer.availability + + fun ensureStarted() { + SwipeNavSettingsManager.onEnabledChanged = { observer.setEnabled(SystemUtils.isMacOS && it) } + observer.setEnabled(SystemUtils.isMacOS && SwipeNavSettingsManager.isEnabled()) + } + + fun register(onEnded: (ScrollGestureEnd) -> Unit): ScrollGestureClaim = observer.register(onEnded) + + fun isAvailable(): Boolean = availability.value == ScrollPhaseAvailability.AVAILABLE } -internal class ScrollGestureClaim internal constructor( - private val claimantId: Long, +internal class ScrollGestureClaim( + private val claim: () -> String?, + private val unregister: () -> Unit, ) : AutoCloseable { - fun currentGestureToken(): String? = MacOSScrollGesturePhases.claimCurrent(claimantId) + fun currentGestureToken(): String? = claim() - override fun close() = MacOSScrollGesturePhases.unregister(claimantId) + override fun close() = unregister() } -private fun interface ScrollEventCallback : Callback { +internal fun interface ScrollEventCallback : Callback { fun invoke( proxy: Pointer?, type: Int, @@ -405,8 +496,8 @@ private fun interface ScrollEventCallback : Callback { } // These names are the native ApplicationServices symbols JNA binds verbatim. -@Suppress("FunctionNaming", "LongParameterList", "ktlint:standard:function-naming") -private interface ApplicationServices : Library { +@Suppress("FunctionNaming", "LongParameterList", "TooManyFunctions", "ktlint:standard:function-naming") +internal interface ApplicationServices : Library { fun CGPreflightListenEventAccess(): Boolean fun CGEventTapCreate( @@ -442,7 +533,21 @@ private interface ApplicationServices : Library { mode: Pointer, ) - fun CFRunLoopRun() + fun CFRunLoopRunInMode( + mode: Pointer, + seconds: Double, + returnAfterSourceHandled: Boolean, + ): Int + + fun CFRunLoopRemoveSource( + loop: Pointer, + source: Pointer, + mode: Pointer, + ) + + fun CFMachPortInvalidate(port: Pointer) + + fun CFRelease(value: Pointer) } private const val CG_EVENT_SCROLL_WHEEL = 22 diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/SwipeNavAvailability.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/SwipeNavAvailability.kt new file mode 100644 index 0000000000..682b55eeb6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/SwipeNavAvailability.kt @@ -0,0 +1,36 @@ +package ai.rever.boss.plugin.browser + +import ai.rever.boss.config.SwipeNavSettingsManager +import ai.rever.boss.config.parseSwipeNavEnabled + +/** Capability reporting must not mistake a run-loop failure for denied Input Monitoring. */ +internal fun swipeNavSettingsDescription( + envOverride: String?, + availability: ScrollPhaseAvailability, +): String { + val envOwned = parseSwipeNavEnabled(envOverride) != null + val retry = if (envOwned) "Restart BOSS to retry." else "Turn swipe navigation off and on to retry." + val capability = + when (availability) { + ScrollPhaseAvailability.PERMISSION_DENIED -> { + "Allow BOSS under System Settings > Privacy & Security > Input Monitoring. $retry" + } + + ScrollPhaseAvailability.FAILED -> { + "Release detection stopped. $retry" + } + + ScrollPhaseAvailability.STARTING -> { + "Starting trackpad release detection." + } + + else -> { + "Swipe right with two fingers to go back, left to go forward." + } + } + return if (envOwned) { + "Set by ${SwipeNavSettingsManager.KEY}=$envOverride in the environment. $capability" + } else { + capability + } +} diff --git a/composeApp/src/desktopMain/resources/browser/swipe-nav.js b/composeApp/src/desktopMain/resources/browser/swipe-nav.js index 775c53e202..0a9c0c7405 100644 --- a/composeApp/src/desktopMain/resources/browser/swipe-nav.js +++ b/composeApp/src/desktopMain/resources/browser/swipe-nav.js @@ -377,6 +377,13 @@ } function onWheel(event) { + // Cheap filters precede the synchronous renderer-to-host claim. Do not skip vertical + // pixel events: their initial scroll chain must retain ownership if the contact curls. + if (switchedOff()) { reset(); return; } + if (event.deltaMode !== 0) { + if (nativeGestureId !== null) abandon(); + return; + } var bridge = w.__bossSwipeNav; var activeId = null; try { @@ -517,7 +524,9 @@ // it anywhere is moot at best; this only tears down the affordance so nothing outlives the // document it was drawn into. w.addEventListener('pagehide', reset, { capture: true }); - w.addEventListener('blur', reset, { capture: true }); + w.addEventListener('blur', function (event) { + if (event.target === w) reset(); + }, { capture: false }); w.addEventListener('visibilitychange', function () { if (w.document.hidden) reset(); }, { capture: true }); diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/SwipeNavSettingsTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/SwipeNavSettingsTest.kt index 8b64c4fd2b..bccbf0f858 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/SwipeNavSettingsTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/SwipeNavSettingsTest.kt @@ -129,4 +129,19 @@ class SwipeNavSettingsTest { SwipeNavSettingsManager.set(true) assertEquals(true, parseSwipeNavEnabled(System.getProperty(SwipeNavSettingsManager.KEY))) } + + @Test + fun `publishing notifies the observer of the effective setting`() { + val previous = SwipeNavSettingsManager.onEnabledChanged + val values = mutableListOf() + try { + SwipeNavSettingsManager.onEnabledChanged = values::add + SwipeNavSettingsManager.set(false) + SwipeNavSettingsManager.set(true) + val override = parseSwipeNavEnabled(SwipeNavSettingsManager.envOverride()) + assertEquals(listOf(override ?: false, override ?: true), values) + } finally { + SwipeNavSettingsManager.onEnabledChanged = previous + } + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ScrollPhaseObserverTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ScrollPhaseObserverTest.kt new file mode 100644 index 0000000000..033bc132df --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ScrollPhaseObserverTest.kt @@ -0,0 +1,321 @@ +package ai.rever.boss.plugin.browser + +import com.sun.jna.Pointer +import java.lang.reflect.Proxy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ScrollPhaseObserverTest { + private class Harness { + val tasks = ArrayDeque<() -> Unit>() + val properties = mutableMapOf() + val publications = mutableListOf>() + val errors = mutableListOf() + var event: (Long, Long, Double, Double) -> Unit = { _, _, _, _ -> } + var interrupted: () -> Unit = {} + var opens = 0 + var closes = 0 + var deny = false + var duringPoll: () -> Boolean = { false } + val observer = + ScrollPhaseObserver( + open = { onEvent, onInterrupted -> + opens++ + if (deny) throw ScrollPhasePermissionDenied() + event = onEvent + interrupted = onInterrupted + object : ScrollPhaseSession { + override fun poll(): Boolean = duringPoll() + + override fun close() { + closes++ + } + } + }, + launch = { tasks.addLast(it) }, + publish = { key, value -> + properties[key] = value + publications.add(key to value) + }, + reportFailure = { errors.add(it) }, + ) + + fun run() = tasks.removeFirst().invoke() + + fun begin() = event(1, 0, -20.0, 0.0) + } + + @Test + fun `registration and status reads do not start observation while disabled`() { + val h = Harness() + val claim = h.observer.register { error("unexpected end") } + repeat(3) { h.observer.setEnabled(false) } + assertEquals(ScrollPhaseAvailability.DISABLED, h.observer.availability.value) + assertNull(claim.currentGestureToken()) + assertTrue(h.tasks.isEmpty()) + assertEquals(0, h.opens) + claim.close() + } + + @Test + fun `disable cancels claimants immediately and reenable fences old callbacks`() { + val h = Harness() + val ends = mutableListOf() + val claim = h.observer.register(ends::add) + h.observer.setEnabled(true) + h.duringPoll = { + h.begin() + assertTrue(claim.currentGestureToken()!!.startsWith("1:")) + val staleEvent = h.event + h.observer.setEnabled(false) + assertNull(claim.currentGestureToken()) + assertEquals(listOf(true), ends.map { it.cancelled }) + h.observer.setEnabled(true) + staleEvent(1, 0, -200.0, 0.0) + true + } + h.run() + assertEquals(1, h.closes) + h.duringPoll = { + h.begin() + assertTrue(claim.currentGestureToken()!!.startsWith("2:")) + h.observer.setEnabled(false) + true + } + h.run() + assertEquals(2, h.opens) + assertEquals(2, h.closes) + assertEquals(listOf(1L, 2L), ends.map { it.id }) + } + + @Test + fun `denied permission is reported separately and toggle retries`() { + val h = Harness() + h.deny = true + h.observer.setEnabled(true) + h.run() + assertEquals(ScrollPhaseAvailability.PERMISSION_DENIED, h.observer.availability.value) + assertTrue(h.errors.isEmpty()) + h.observer.setEnabled(false) + h.deny = false + h.observer.setEnabled(true) + h.duringPoll = { + h.observer.setEnabled(false) + true + } + h.run() + assertEquals(2, h.opens) + assertEquals(1, h.closes) + } + + @Test + fun `unexpected return and thrown failure both cancel release and can restart`() { + for (throws in listOf(false, true)) { + val h = Harness() + val ends = mutableListOf() + val claim = h.observer.register(ends::add) + h.duringPoll = { + h.begin() + claim.currentGestureToken() + if (throws) error("failed poll") + false + } + h.observer.setEnabled(true) + h.run() + assertEquals(ScrollPhaseAvailability.FAILED, h.observer.availability.value) + assertEquals(listOf(true), ends.map { it.cancelled }) + assertNull(claim.currentGestureToken()) + assertEquals("unavailable", h.properties[MacOSScrollGesturePhases.PHASE_PROPERTY]) + assertEquals(1, h.closes) + h.observer.setEnabled(true) + h.duringPoll = { + h.observer.setEnabled(false) + true + } + h.run() + assertEquals(2, h.opens) + } + } + + @Test + fun `movement does not republish and terminal history survives rapid contacts`() { + val h = Harness() + val ends = mutableListOf() + val claim = + h.observer.register { + assertTrue(h.properties[MacOSScrollGesturePhases.TERMINALS_PROPERTY]!!.contains("${it.id}:")) + ends.add(it) + } + h.observer.setEnabled(true) + h.duringPoll = { + repeat(40) { + h.begin() + claim.currentGestureToken() + val before = h.publications.size + repeat(20) { h.event(2, 0, -1.0, 0.0) } + assertEquals(before, h.publications.size) + h.event(4, 0, 0.0, 0.0) + h.event(4, 0, 0.0, 0.0) // Duplicate terminal is inert. + } + h.begin() + val history = h.properties[MacOSScrollGesturePhases.TERMINALS_PROPERTY]!!.split(';') + assertEquals(32, history.size) + assertTrue(history.first().startsWith("9:ended:")) + assertTrue(history.last().startsWith("40:ended:")) + assertEquals(40, ends.size) + h.observer.setEnabled(false) + true + } + h.run() + } + + @Test + fun `replacement and interrupted taps cancel every registered claimant once`() { + val h = Harness() + val first = mutableListOf() + val second = mutableListOf() + val a = h.observer.register(first::add) + val b = h.observer.register(second::add) + h.observer.setEnabled(true) + h.duringPoll = { + h.begin() + a.currentGestureToken() + b.currentGestureToken() + h.begin() + assertEquals(listOf(1L), first.map { it.id }) + assertEquals(first, second) + a.currentGestureToken() + b.close() + h.interrupted() + assertEquals(listOf(1L, 2L), first.map { it.id }) + assertEquals(1, second.size) + assertTrue(first.all { it.cancelled }) + assertNull(a.currentGestureToken()) + h.observer.setEnabled(false) + true + } + h.run() + } + + @Test + fun `native setup failures release every acquired resource`() { + val failurePoints = + listOf( + "CGEventTapCreate", + "CFMachPortCreateRunLoopSource", + "CFRunLoopAddSource", + "CGEventTapEnable", + "none", + ) + for (failAt in failurePoints) { + val calls = mutableListOf() + val api = + Proxy.newProxyInstance( + ApplicationServices::class.java.classLoader, + arrayOf(ApplicationServices::class.java), + ) { _, method, _ -> + calls.add(method.name) + if (method.name == failAt) { + if (failAt == "CFRunLoopAddSource" || failAt == "CGEventTapEnable") error("setup failed") + null + } else { + when (method.name) { + "CGPreflightListenEventAccess" -> true + "CFRunLoopGetCurrent", "CGEventTapCreate", "CFMachPortCreateRunLoopSource" -> Pointer(1) + "CFRunLoopRunInMode" -> 4 + else -> null + } + } + } as ApplicationServices + val result = runCatching { openScrollPhaseSession(api, Pointer(2), { _, _, _, _ -> }, {}) } + if (failAt == "none") { + assertTrue(result.getOrThrow().poll()) + result.getOrThrow().close() + } else { + assertTrue(result.isFailure, failAt) + } + val expectedReleases = + when (failAt) { + "CGEventTapCreate" -> 0 + "CFMachPortCreateRunLoopSource" -> 1 + else -> 2 + } + assertEquals(expectedReleases, calls.count { it == "CFRelease" }, failAt) + assertEquals(expectedReleases > 0, "CFMachPortInvalidate" in calls, failAt) + } + } + + @Test + fun `settings distinguish permission failure and environment ownership`() { + val denied = swipeNavSettingsDescription(null, ScrollPhaseAvailability.PERMISSION_DENIED) + assertTrue("Input Monitoring" in denied) + val failure = swipeNavSettingsDescription(null, ScrollPhaseAvailability.FAILED) + assertFalse("Input Monitoring" in failure) + assertTrue("off and on" in failure) + val envFailure = swipeNavSettingsDescription("true", ScrollPhaseAvailability.FAILED) + assertTrue("BOSS_BROWSER_SWIPE_NAV=true" in envFailure) + assertTrue("Release detection stopped" in envFailure) + assertTrue("Restart BOSS" in envFailure) + assertFalse("off and on" in envFailure) + assertFalse("environment" in swipeNavSettingsDescription("typo", ScrollPhaseAvailability.AVAILABLE)) + assertFalse("Input Monitoring" in swipeNavSettingsDescription("false", ScrollPhaseAvailability.DISABLED)) + } + + @Test + fun `a queued start disabled before setup acquires no native resources`() { + val h = Harness() + h.observer.setEnabled(true) + h.observer.setEnabled(false) + h.run() + assertEquals(0, h.opens) + assertEquals(ScrollPhaseAvailability.DISABLED, h.observer.availability.value) + } + + @Test + fun `claims racing native release are either delivered or refused`() { + val h = Harness() + val delivered = + java.util.concurrent.atomic + .AtomicReference() + val claim = h.observer.register { delivered.set(it) } + h.observer.setEnabled(true) + h.duringPoll = { + repeat(30) { + h.begin() + delivered.set(null) + val claimed = + java.util.concurrent.atomic + .AtomicReference() + val start = java.util.concurrent.CountDownLatch(1) + val claimer = + Thread { + start.await() + claimed.set(claim.currentGestureToken()) + } + val releaser = + Thread { + start.await() + h.event(4, 0, 0.0, 0.0) + } + claimer.start() + releaser.start() + start.countDown() + claimer.join(5000) + releaser.join(5000) + assertFalse(claimer.isAlive || releaser.isAlive, "claim/release deadlocked") + if (claimed.get() != null) { + assertEquals(claimed.get()!!.substringBefore(":").toLong(), delivered.get()?.id) + } else { + assertNull(delivered.get()) + } + assertNull(claim.currentGestureToken()) + } + h.observer.setEnabled(false) + true + } + h.run() + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/SwipeNavParityTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/SwipeNavParityTest.kt new file mode 100644 index 0000000000..0f02c75865 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/SwipeNavParityTest.kt @@ -0,0 +1,90 @@ +package ai.rever.boss.plugin.browser + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.double +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SwipeNavParityTest { + private val root = + generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "scripts/test/test-swipe-nav.js").isFile } + + @Test + fun `native cancellation constants stay coupled to the page`() { + val source = File(root, "composeApp/src/desktopMain/resources/browser/swipe-nav.js").readText() + val constants = + mapOf( + "COMMIT_PX" to SWIPE_COMMIT_PX, + "MIN_EVENTS" to SWIPE_MIN_EVENTS.toDouble(), + "CANCEL_STRONG_RATIO" to SWIPE_CANCEL_STRONG_RATIO, + "CANCEL_MIXED_RATIO" to SWIPE_CANCEL_MIXED_RATIO, + "CANCEL_VERTICAL_LOW" to SWIPE_CANCEL_VERTICAL_LOW, + "CANCEL_VERTICAL_HIGH" to SWIPE_CANCEL_VERTICAL_HIGH, + ) + constants.forEach { (name, value) -> + val expression = if (name.startsWith("CANCEL_VERTICAL")) "COMMIT_PX \\* " else "" + val match = Regex("var $name = $expression([0-9.]+);").find(source) + assertEquals(value, match?.groupValues?.get(1)?.toDouble(), name) + } + } + + @Test + fun `real native terminal evidence passes the page regression scenarios`() { + val cases = Json.parseToJsonElement(File(root, "scripts/test/swipe-nav-cases.json").readText()).jsonArray + val results = + buildJsonObject { + cases.forEach { fixture -> + val fields = fixture.jsonObject + var state = scrollGestureTransition(ScrollGestureSnapshot(0, false), 1, 0).first + fields.getValue("samples").jsonArray.forEach { sample -> + state = + scrollGestureTransition( + state, + 2, + 0, + sample.jsonArray[0].jsonPrimitive.double, + sample.jsonArray[1].jsonPrimitive.double, + ).first + } + val cancelled = fields.getValue("cancelled").jsonPrimitive.content == "true" + val end = scrollGestureTransition(state, if (cancelled) 8 else 4, 0).second!! + put( + fields.getValue("name").jsonPrimitive.content, + buildJsonObject { + put("statement", BrowserSwipeNavScript.release(end)) + put("rejected", end.rejected) + put("cancelled", end.cancelled) + }, + ) + } + } + val evidence = File.createTempFile("swipe-native-results", ".json") + val output = File.createTempFile("swipe-native-page", ".log") + try { + evidence.writeText(results.toString()) + val process = + ProcessBuilder( + "node", + File(root, "scripts/test/test-swipe-nav.js").absolutePath, + "--native-results", + evidence.absolutePath, + ).redirectErrorStream(true).redirectOutput(output).start() + val completed = process.waitFor(30, TimeUnit.SECONDS) + if (!completed) process.destroyForcibly() + assertTrue(completed, "page parity suite timed out") + assertEquals(0, process.exitValue(), output.readText()) + } finally { + evidence.delete() + output.delete() + } + } +} diff --git a/docs/review/pr650-followup.md b/docs/review/pr650-followup.md new file mode 100644 index 0000000000..8308f9deb5 --- /dev/null +++ b/docs/review/pr650-followup.md @@ -0,0 +1,52 @@ +# PR #650 review follow-up + +This change addresses the four blocking findings in the [maintainer review](https://github.com/risa-labs-inc/BossConsole/pull/650#issuecomment-5663173004). +The companion transport consumer is updated in [fluck-browser #45](https://github.com/risa-labs-inc/boss-plugin-fluck-browser/pull/45). + +## Changes and evidence + +| Finding | Change | Regression coverage | +| --- | --- | --- | +| Native/page cancellation differs | Native code retains leading vertical travel but evaluates cancellation only on horizontal samples, as the page does. Commit qualification waits for three horizontal samples; zero-crossing reversal matches the page. | Shared sample fixtures run through the actual native reducer and page script, including leading noise, vertical curl, easing, reversal, post-threshold drift, short flick and cancellation. Constants are pinned across languages. | +| Element blur cancels a swipe | Only a blur targeted at the window resets the detector. | Element focus transfer commits; window blur and hidden-document transitions cancel. | +| Off switch and failure lifecycle | Startup binds observation to the effective setting. Native setup/poll/cleanup runs on a daemon worker. Disabled sessions cancel immediately; their callbacks cannot join a replacement session. Setup failures release partial resources, and run-loop failures cancel and permit retry. | Disabled startup, queued startup cancellation, disable/re-enable, denied permission, run-loop return/exception, partial allocation, replacement, tap interruption and concurrent claim/release tests. Settings descriptions distinguish permission denial from failure, including environment-owned settings. | +| Wheel hot path and lossy companion transport | Disabled and line-mode wheels avoid host IPC. Vertical pixel samples still retain initial scroll ownership. Active publication occurs once per contact. The host retains 32 terminal records, which the companion reconciles before a new pointer event or watchdog poll can discard the old contact. | Existing nested-scroller tests retained. Publication-count and bounded-history tests cover rapid contacts; companion tests cover recovery, cancellation, missing/evicted evidence and stale watchdog ownership. | + +Terminal history is a bounded, non-destructive record, not an unbounded delivery queue. A consumer delayed past 32 subsequent releases cancels if its record has been evicted. Updated companion code also recognizes that these hosts cancel on failure themselves, so the old ten-second watchdog does not cancel a stationary native hold. + +## Other review observations + +- Non-macOS regression and iframe release were ruled out by the maintainer: both settings and injection are macOS-gated, and the detector skips subframes. These gates remain intact. +- Nested horizontal scrollers retain the contact even at an edge, and momentum is excluded. Both are deliberate requirements and remain tested. +- No speculative changes were made to native delta units, the 1 ms renderer timestamp tolerance, or initial direction thresholds. The native/page direction policies now agree; the early-timestamp acceptance boundary is tested. Hardware calibration is still required below. +- The 400 ms same-direction host gate remains conservative across document replacement. Its comments no longer refer to the removed inactivity timer. It also limits repeated page-originated bridge calls; two intentional same-direction requests inside the window can still be refused. +- Native snapshot claims now consult `observable`. A replacement Begin/MayBegin cancels prior claimants. Source/tap resources and callback retention have explicit cleanup. +- The production bridge still supplies its native claim. Its nullable default remains useful to existing bridge-only tests; this was not classified as a production defect by the maintainer. +- The existing JNA boolean mapping remains unchanged; the maintainer requested evidence before speculative ABI changes. +- Duplicate/stale releases, unavailable observation and visibility reset have page regressions. The host-generated release statement itself is executed by the cross-layer suite. + +## Validation + +Local checks: + +- `node scripts/test/test-swipe-nav.js` +- `:composeApp:desktopTest` restricted to BrowserSwipeNavTest, MacOSScrollGesturePhasesTest, ScrollPhaseObserverTest, SwipeNavParityTest and SwipeNavSettingsTest +- `:composeApp:detekt :composeApp:ktlintCheck` +- Companion: `CI=true ./gradlew test buildPluginJar` using its downloaded API dependency, matching CI + +GitHub CI must pass for the final pushed commits. Local automated results do not establish physical trackpad behavior. + +## Required physical macOS check + +Still to be performed on the paired host/plugin build; no new hardware validation is claimed: + +- Hold a completed swipe stationary, including beyond ten seconds: no navigation until release. +- Release a qualifying swipe: one navigation; repeat in both directions. +- Ease below the threshold and reverse through zero: no navigation. +- Try a short flick with a large momentum tail: momentum must not supply missing finger travel. +- Scroll nested horizontal content from its interior and either edge: retain ownership throughout. +- Repeat on the browser home surface, including rapid successive contacts while navigation is busy. +- Compare affordance progress and release behavior for slow/fractional drags and browser zoom. Native points, CSS pixels and Compose units remain different coordinate spaces; the companion's existing calibration is preserved. +- Disable/re-enable in Settings; where applicable, verify denied Input Monitoring and recovery after granting it. + +The reviewer requested this check before merge. Keep that requirement visible rather than treating green automated tests as a substitute. diff --git a/scripts/test/swipe-nav-cases.json b/scripts/test/swipe-nav-cases.json new file mode 100644 index 0000000000..cdaa1adae6 --- /dev/null +++ b/scripts/test/swipe-nav-cases.json @@ -0,0 +1,11 @@ +[ + {"name": "leading vertical noise", "samples": [[0, 1], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0]], "expected": ["back"], "cancelled": false}, + {"name": "vertical curl cancelled", "samples": [[0, 80], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0]], "expected": [], "cancelled": false}, + {"name": "ease below threshold", "samples": [[-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [50, 0]], "expected": [], "cancelled": false}, + {"name": "reversal", "samples": [[-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [150, 0]], "expected": [], "cancelled": false}, + {"name": "zero crossing reversal", "samples": [[-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [120, 0], [-20, 0]], "expected": [], "cancelled": false}, + {"name": "vertical after threshold", "samples": [[-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [0, 300], [-1, 0]], "expected": ["back"], "cancelled": false}, + {"name": "short flick", "samples": [[-10, 0], [-10, 0], [-10, 0]], "expected": [], "cancelled": false}, + {"name": "cancelled contact", "samples": [[-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0]], "expected": [], "cancelled": true}, + {"name": "forward", "samples": [[20, 0], [20, 0], [20, 0], [20, 0], [20, 0], [20, 0]], "expected": ["forward"], "cancelled": false} +] diff --git a/scripts/test/test-swipe-nav.js b/scripts/test/test-swipe-nav.js index 60f01cdd2a..5f19841c2c 100644 --- a/scripts/test/test-swipe-nav.js +++ b/scripts/test/test-swipe-nav.js @@ -39,12 +39,6 @@ function check(name, cond, detail) { console.log(` FAIL ${name}${detail === undefined ? '' : ` -> ${detail}`}`); } } -/** Smallest distance between consecutive commits; Infinity when there are fewer than two. */ -function minGap(times) { - let smallest = Infinity; - for (let i = 1; i < times.length; i++) smallest = Math.min(smallest, times[i] - times[i - 1]); - return smallest; -} function eq(name, actual, expected) { check( name, @@ -89,6 +83,7 @@ function newPage(js, options = {}) { let clockMs = 1000; let preventedDefaults = 0; let nativeGestureId = '1'; + let claimCalls = 0; let nativeX = 0; let nativeBeganAt = 0; @@ -204,7 +199,10 @@ function newPage(js, options = {}) { // The clock is recorded alongside the direction: the host's SWIPE_NAV_DEBOUNCE_MS rests on // "two commits are never closer than GESTURE_GAP_MS", and that claim lives in this script. sandbox.window[hostProps.bridge] = { - activeGestureId: () => nativeGestureId === null ? null : `${nativeGestureId}:${nativeBeganAt}`, + activeGestureId: () => { + claimCalls++; + return nativeGestureId === null ? null : `${nativeGestureId}:${nativeBeganAt}`; + }, navigate: (d) => { navigated.push(d); navigatedAt.push(clockMs); @@ -238,6 +236,12 @@ function newPage(js, options = {}) { return { element, body, + claimCalls: () => claimCalls, + executeRelease: statement => vm.runInContext(statement, sandbox), + releaseRaw: (...args) => sandbox.window[hostProps.release](...args), + elementBlur: () => (listeners.blur || []).forEach(f => f({ target: element({ tagName: "INPUT" }) })), + windowBlur: () => (listeners.blur || []).forEach(f => f({ target: sandbox.window })), + hide: () => { sandbox.document.hidden = true; (listeners.visibilitychange || []).forEach(f => f()); }, navigated, navigatedAt, registrations, @@ -694,7 +698,7 @@ console.log('\ngestures that must not navigate'); { const p = newPage(js); p.wheelRaw({ deltaMode: 1, deltaX: -40, deltaY: 0, target: p.body, composedPath: () => [p.body] }); - p.swipe(12, -10); + eq('line-mode wheels avoid host IPC', p.claimCalls(), 0); p.settle(); eq('anything that is not pixel-mode', p.navigated, []); } @@ -903,6 +907,62 @@ console.log('\nthe affordance'); } console.log(''); +// Focus transfer inside the document must not cancel a physical contact. +for (const kind of ['elementBlur', 'windowBlur', 'hide']) { + const p = newPage(js); + p.swipe(6, -20); + p[kind](); + p.release(); + eq(kind + ' release semantics', p.navigated, kind === 'elementBlur' ? ['back'] : []); +} +{ + const p = newPage(js, { state: { enabled: false, back: true, forward: true } }); + p.swipe(6, 0, 20); + eq('disabled vertical scrolling avoids host IPC', p.claimCalls(), 0); +} +{ + const p = newPage(js); + p.swipe(6, -20); + p.releaseRaw('1', false, -120, false); + p.releaseRaw('1', false, -120, false); + eq('duplicate release commits once', p.navigated, ['back']); +} +{ + const p = newPage(js); + p.newGesture(); + p.swipe(6, -20); + p.releaseRaw('1', true, 0, true); + p.release(); + eq('stale release preserves the newer contact', p.navigated, ['back']); +} +{ + const p = newPage(js); + p.momentumWheel(-20, 0); + p.swipe(6, -20); + eq('unavailable native contact cannot navigate', p.navigated, []); +} +{ + const p = newPage(js); + p.newGesture(); + p.swipe(6, -20, 0, undefined, undefined, { timeStamp: 999 }); + p.release(); + eq('one millisecond early renderer stamps are accepted', p.navigated, ['back']); +} +// Kotlin produces these terminal values by running the real native reducer over the same +// samples. This exercises the generated release statement as well as recognizer parity. +const nativeResultsAt = process.argv.indexOf('--native-results'); +if (nativeResultsAt >= 0) { + const fixtures = JSON.parse(fs.readFileSync(path.join(__dirname, 'swipe-nav-cases.json'), 'utf8')); + const results = JSON.parse(fs.readFileSync(process.argv[nativeResultsAt + 1], 'utf8')); + for (const fixture of fixtures) { + const p = newPage(js); + for (const [dx, dy] of fixture.samples) p.wheel(dx, dy); + const end = results[fixture.name]; + p.executeRelease(end.statement); + eq('native/page parity: ' + fixture.name, p.navigated, fixture.expected); + } +} + if (failures) { console.log(`${failures} failing check(s)`); process.exit(1); From 9253ed5e4779a517d9b68a4a653c781b57059652 Mon Sep 17 00:00:00 2001 From: Shivanshu Date: Tue, 15 Sep 2026 03:39:22 +0530 Subject: [PATCH 12/49] Publish prior contact termination for home swipe attribution --- AGENTS.md | 18 +++++---- .../boss/plugin/browser/BrowserHandleImpl.kt | 7 ++++ .../browser/MacOSScrollGesturePhases.kt | 34 ++++++++++++----- .../plugin/browser/SwipeNavAvailability.kt | 3 +- .../resources/browser/swipe-nav.js | 7 +--- .../plugin/browser/BrowserSwipeNavTest.kt | 4 -- .../plugin/browser/ScrollPhaseObserverTest.kt | 38 +++++++++++++++++++ .../boss/plugin/browser/SwipeNavParityTest.kt | 17 ++++++--- docs/review/pr650-followup.md | 14 +++++-- scripts/test/swipe-nav-cases.json | 1 + 10 files changed, 106 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed0b83426f..f44145d8c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -768,6 +768,9 @@ restart. There is no Settings row and no per-site exclusion. `window.__bossInteractionStarted`. The sanitizers bound what can be *smuggled* through; nothing bounds a site lying about its own usage. Treat these as indicative, not as measurements, wherever a site has an incentive to lie. +- **A page can observe the current trackpad gesture token.** The injected swipe bridge exposes + the process-wide contact id and begin epoch while fingers are down. It does not expose deltas, + but a page can poll the bridge and infer that a trackpad contact is active. - **Every project the user opens is now on the bus, not only plugin-initiated ones.** `ProjectChangeEvent` used to be published from `ProjectDataProviderImpl.selectProject` alone, so a path reached plugins only when a plugin had asked for the switch. It is @@ -817,8 +820,9 @@ the native source and tap within the bounded run-loop poll. Re-enabling retries run-loop failures cancel the contact, clean up resources and report a distinct failure state. Availability reads and browser registration do not start native observation. -`boss.browser.swipe.phase` remains compatible: `id:active:beganAtEpochMs` or -`id:ended|cancelled:netX:verticalPath:pageRejected:reversed`. Active publication occurs only at +`boss.browser.swipe.phase` remains compatible: `id:active:beganAtEpochMs[:previousTerminatedAtEpochMs]` +or `id:ended|cancelled:netX:verticalPath:nativeRejected:reversed`. The fifth terminal field is the +native reducer's verdict, not the page's. Active publication occurs only at contact begin, not on every movement. `boss.browser.swipe.terminals` additionally retains the last 32 terminal records, separated by semicolons, published before the next active contact. Companion fluck-browser#45 reconciles by contact ID both before handling a new wheel and in its watchdog. @@ -828,12 +832,10 @@ trackpad calibration still needs to cover browser zoom, slow drags and the separ surface. `SwipeNavParityTest` runs shared sample fixtures through the native reducer and the actual page script, including the host-generated release statement; it also pins cancellation constants. -One cross-process ordering limit remains: JxBrowser drops the AWT event timestamp before building -the renderer wheel event. The epoch check can reject an event Chromium timestamps before the new -native Begin, but a renderer backlog could stamp an old event after that Begin and associate it -with the new id. Native final displacement still prevents a delayed reversal from committing; the -remaining cross-gesture attribution case needs real backlog testing before adding a custom FIFO -between the CoreGraphics tap and JxBrowser's input callback. +One cross-process ordering limit remains: the cutoff rejects AWT events stamped at or before the +previous native termination, but cannot identify an old OS event that AWT dispatch stamps only +after the new contact begins. Arbitrarily delayed dispatch still needs real backlog testing +before adding a custom FIFO between the CoreGraphics tap and JxBrowser's input callback. **Past the commit distance, vertical drift stops cancelling** (`reachedCommit`). Vertical is a path length and only ever grows, so every event after the crossing was one more chance to cancel a swipe diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt index 4286a5da4a..2bb0040e17 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserHandleImpl.kt @@ -2053,6 +2053,13 @@ internal class BrowserHandleImpl( private fun onSwipeGestureEnded(end: ScrollGestureEnd) { if (!isValid) return pageInjectScope.launch(pageInjectDispatcher) { + if (end.rejected && !end.cancelled) { + logger.debug( + LogCategory.BROWSER, + "Native trackpad release vetoed browser swipe", + mapOf("gestureId" to end.id, "horizontal" to end.accumX, "verticalPath" to end.verticalPath), + ) + } val statement = BrowserSwipeNavScript.release(end) runCatching { browser.mainFrame().ifPresent { it.executeJavaScript(statement) } } } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt index 0d6c302a7f..8bc2b4decb 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/MacOSScrollGesturePhases.kt @@ -17,6 +17,7 @@ import java.util.concurrent.atomic.AtomicLong internal data class ScrollGestureSnapshot( val id: Long, val active: Boolean, + /** Redundant with active today; retained to make claim visibility an explicit state boundary. */ val observable: Boolean = active, val horizontalEvents: Int = 0, val accumX: Double = 0.0, @@ -204,6 +205,7 @@ internal class ScrollPhaseObserver( private val launch: (() -> Unit) -> Unit, private val publish: (String, String) -> Unit, private val reportFailure: (Throwable) -> Unit, + private val clock: () -> Long = System::currentTimeMillis, ) { private val lock = Any() private val listeners = mutableMapOf Unit>() @@ -211,6 +213,7 @@ internal class ScrollPhaseObserver( private var snapshot = ScrollGestureSnapshot(0, false) private var generation = 0L private var enabled = false + private var previousTerminatedAtEpochMs: Long? = null private val terminals = ArrayDeque() private val mutableAvailability = MutableStateFlow(ScrollPhaseAvailability.DISABLED) val availability = mutableAvailability.asStateFlow() @@ -223,6 +226,8 @@ internal class ScrollPhaseObserver( fun setEnabled(value: Boolean) { val sessionId = synchronized(lock) { + // Permission denial retries only after an explicit off/on transition, matching the + // Settings recovery instructions; ordinary failures may retry a redundant enable. if (enabled == value && (!value || availability.value != ScrollPhaseAvailability.FAILED)) return enabled = value generation++ @@ -260,7 +265,7 @@ internal class ScrollPhaseObserver( if (!isCurrent(id)) return open( { phase, momentum, dx, dy -> consume(id, phase, momentum, dx, dy) }, - { synchronized(lock) { if (isCurrent(id)) cancelCurrent() } }, + { synchronized(lock) { if (isCurrent(id)) cancelCurrent(clock()) } }, ).use { session -> synchronized(lock) { if (!isCurrent(id)) return @@ -277,7 +282,7 @@ internal class ScrollPhaseObserver( } finally { synchronized(lock) { if (isCurrent(id)) { - cancelCurrent() + cancelCurrent(clock()) publish(MacOSScrollGesturePhases.PHASE_PROPERTY, "unavailable") if (availability.value == ScrollPhaseAvailability.AVAILABLE) { mutableAvailability.value = ScrollPhaseAvailability.FAILED @@ -293,7 +298,7 @@ internal class ScrollPhaseObserver( ): Unit = synchronized(lock) { if (!isCurrent(id)) return - cancelCurrent() + cancelCurrent(clock()) mutableAvailability.value = if (error is ScrollPhasePermissionDenied) { ScrollPhaseAvailability.PERMISSION_DENIED @@ -312,23 +317,32 @@ internal class ScrollPhaseObserver( ): Unit = synchronized(lock) { if (!isCurrent(id)) return + val callbackAt = clock() val before = snapshot - val (after, end) = scrollGestureTransition(before, phase, momentum, dx, dy, System.currentTimeMillis()) + val (after, end) = scrollGestureTransition(before, phase, momentum, dx, dy, callbackAt) snapshot = after // Publish the replaced contact's cancellation before announcing the new contact. - end?.let(::deliver) + end?.let { deliver(it, callbackAt) } if (after.active && (!before.active || after.id != before.id)) { - publish(MacOSScrollGesturePhases.PHASE_PROPERTY, "${after.id}:active:${after.beganAtEpochMs}") + val previous = previousTerminatedAtEpochMs?.let { ":$it" }.orEmpty() + publish( + MacOSScrollGesturePhases.PHASE_PROPERTY, + "${after.id}:active:${after.beganAtEpochMs}$previous", + ) } } - private fun cancelCurrent() { + private fun cancelCurrent(endedAtEpochMs: Long = clock()) { val (after, end) = scrollGestureTransition(snapshot, CG_SCROLL_PHASE_CANCELLED, 0) snapshot = after - end?.let(::deliver) + end?.let { deliver(it, endedAtEpochMs) } } - private fun deliver(end: ScrollGestureEnd) { + private fun deliver( + end: ScrollGestureEnd, + endedAtEpochMs: Long, + ) { + previousTerminatedAtEpochMs = endedAtEpochMs val state = if (end.cancelled) "cancelled" else "ended" val wire = "${end.id}:$state:${end.accumX}:${end.verticalPath}:${end.rejected}:${end.reversed}" terminals.addLast(wire) @@ -336,6 +350,8 @@ internal class ScrollPhaseObserver( // History comes first: a reader observing the next active id can already recover the end. publish(MacOSScrollGesturePhases.TERMINALS_PROPERTY, terminals.joinToString(";")) publish(MacOSScrollGesturePhases.PHASE_PROPERTY, wire) + // Listener callbacks must only enqueue work. This tap thread holds the monitor also used + // by page claims, so synchronously entering the renderer here would deadlock. end.claimantIds.forEach { id -> listeners[id]?.let { runCatching { it(end) } } } } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/SwipeNavAvailability.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/SwipeNavAvailability.kt index 682b55eeb6..d3124ff213 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/SwipeNavAvailability.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/SwipeNavAvailability.kt @@ -13,7 +13,8 @@ internal fun swipeNavSettingsDescription( val capability = when (availability) { ScrollPhaseAvailability.PERMISSION_DENIED -> { - "Allow BOSS under System Settings > Privacy & Security > Input Monitoring. $retry" + "Allow BOSS under System Settings > Privacy & Security > Input Monitoring. " + + "If BOSS is absent, use + and select BOSS.app. $retry" } ScrollPhaseAvailability.FAILED -> { diff --git a/composeApp/src/desktopMain/resources/browser/swipe-nav.js b/composeApp/src/desktopMain/resources/browser/swipe-nav.js index 0a9c0c7405..31dd364b1b 100644 --- a/composeApp/src/desktopMain/resources/browser/swipe-nav.js +++ b/composeApp/src/desktopMain/resources/browser/swipe-nav.js @@ -416,7 +416,7 @@ } nativeGestureId = activeId; - if (rejected || switchedOff()) { + if (rejected) { return; } // Run in bubble phase so a page widget gets the first chance to claim a synthetic or @@ -428,11 +428,6 @@ if (!scrollPath) { scrollPath = eventPath(event); } - // Line and page modes come from sources that are never a trackpad. - if (event.deltaMode !== 0) { - abandon(); - return; - } var dx = event.deltaX || 0; var dy = event.deltaY || 0; // Vertical travel counts from the first event of the gesture, including events with diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavTest.kt index 08e2b80e78..5be4b9c8d0 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavTest.kt @@ -7,7 +7,6 @@ import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -141,9 +140,6 @@ class BrowserSwipeNavTest { @Test fun `the debounce covers a frame and stays below the repeat guard`() { - assertTrue( - SWIPE_NAV_DEBOUNCE_MS < SWIPE_NAV_REPEAT_MS, - ) assertTrue( SWIPE_NAV_DEBOUNCE_MS >= ONE_FRAME_MS, "$SWIPE_NAV_DEBOUNCE_MS must still cover a same-frame double-dispatch", diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ScrollPhaseObserverTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ScrollPhaseObserverTest.kt index 033bc132df..12ac434066 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ScrollPhaseObserverTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ScrollPhaseObserverTest.kt @@ -19,6 +19,7 @@ class ScrollPhaseObserverTest { var opens = 0 var closes = 0 var deny = false + var now = 1_000L var duringPoll: () -> Boolean = { false } val observer = ScrollPhaseObserver( @@ -41,6 +42,7 @@ class ScrollPhaseObserverTest { publications.add(key to value) }, reportFailure = { errors.add(it) }, + clock = { now }, ) fun run() = tasks.removeFirst().invoke() @@ -172,6 +174,41 @@ class ScrollPhaseObserverTest { h.run() } + @Test + fun `active publication carries the previous callback cutoff across replacement and restart`() { + val h = Harness() + h.observer.setEnabled(true) + h.duringPoll = { + h.begin() + assertEquals("1:active:1000", h.properties[MacOSScrollGesturePhases.PHASE_PROPERTY]) + h.now = 1_960 + h.event(4, 0, 0.0, 0.0) + h.now = 2_000 + h.begin() + assertEquals("2:active:2000:1960", h.properties[MacOSScrollGesturePhases.PHASE_PROPERTY]) + h.now = 2_100 + h.begin() + assertEquals("3:active:2100:2100", h.properties[MacOSScrollGesturePhases.PHASE_PROPERTY]) + val replacementPublications = h.publications.takeLast(3) + assertEquals(MacOSScrollGesturePhases.TERMINALS_PROPERTY, replacementPublications[0].first) + assertEquals(MacOSScrollGesturePhases.PHASE_PROPERTY, replacementPublications[1].first) + assertEquals("3:active:2100:2100", replacementPublications[2].second) + h.now = 2_200 + h.observer.setEnabled(false) + h.observer.setEnabled(true) + true + } + h.run() + h.duringPoll = { + h.now = 2_300 + h.begin() + assertEquals("4:active:2300:2200", h.properties[MacOSScrollGesturePhases.PHASE_PROPERTY]) + h.observer.setEnabled(false) + true + } + h.run() + } + @Test fun `replacement and interrupted taps cancel every registered claimant once`() { val h = Harness() @@ -250,6 +287,7 @@ class ScrollPhaseObserverTest { @Test fun `settings distinguish permission failure and environment ownership`() { + assertTrue("Starting" in swipeNavSettingsDescription(null, ScrollPhaseAvailability.STARTING)) val denied = swipeNavSettingsDescription(null, ScrollPhaseAvailability.PERMISSION_DENIED) assertTrue("Input Monitoring" in denied) val failure = swipeNavSettingsDescription(null, ScrollPhaseAvailability.FAILED) diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/SwipeNavParityTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/SwipeNavParityTest.kt index 0f02c75865..dcfe04b1ea 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/SwipeNavParityTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/SwipeNavParityTest.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import java.io.File +import java.io.IOException import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals @@ -72,12 +73,16 @@ class SwipeNavParityTest { try { evidence.writeText(results.toString()) val process = - ProcessBuilder( - "node", - File(root, "scripts/test/test-swipe-nav.js").absolutePath, - "--native-results", - evidence.absolutePath, - ).redirectErrorStream(true).redirectOutput(output).start() + try { + ProcessBuilder( + "node", + File(root, "scripts/test/test-swipe-nav.js").absolutePath, + "--native-results", + evidence.absolutePath, + ).redirectErrorStream(true).redirectOutput(output).start() + } catch (error: IOException) { + throw IOException("SwipeNavParityTest requires node on PATH", error) + } val completed = process.waitFor(30, TimeUnit.SECONDS) if (!completed) process.destroyForcibly() assertTrue(completed, "page parity suite timed out") diff --git a/docs/review/pr650-followup.md b/docs/review/pr650-followup.md index 8308f9deb5..69a9b71b66 100644 --- a/docs/review/pr650-followup.md +++ b/docs/review/pr650-followup.md @@ -2,6 +2,14 @@ This change addresses the four blocking findings in the [maintainer review](https://github.com/risa-labs-inc/BossConsole/pull/650#issuecomment-5663173004). The companion transport consumer is updated in [fluck-browser #45](https://github.com/risa-labs-inc/boss-plugin-fluck-browser/pull/45). +The [latest host re-review](https://github.com/risa-labs-inc/BossConsole/pull/650#issuecomment-5671215711) is addressed as follows: + +- Active records now carry the preceding native termination callback time. Deterministic tests pin ordinary release, direct replacement, publication order and disable/restart persistence. The companion rejects events at or before that cutoff while accepting old three-field active records. +- Input Monitoring instructions cover a fresh install where BOSS is absent from the pane, and the physical checklist makes that case unconditional. Permission denial intentionally retries after the documented off/on transition; failed observation may retry immediately. +- Native release vetoes are debug-logged, and the zoom check explicitly compares native point thresholds with the page's CSS-pixel decision. +- Listener callbacks are documented as enqueue-only while the tap monitor is held. The observable flag remains deliberate redundancy at the claim boundary, and the page-visible gesture token is recorded with browser telemetry. +- Redundant page hot-path gates, an unused test import and a self-comparison were removed. Node absence now reports a useful parity-test error; STARTING copy and leading horizontal jitter have regressions. +- The terminal field is named `nativeRejected` throughout the contract. No threshold, sign convention, permission prompt or locking design changed. ## Changes and evidence @@ -10,7 +18,7 @@ The companion transport consumer is updated in [fluck-browser #45](https://githu | Native/page cancellation differs | Native code retains leading vertical travel but evaluates cancellation only on horizontal samples, as the page does. Commit qualification waits for three horizontal samples; zero-crossing reversal matches the page. | Shared sample fixtures run through the actual native reducer and page script, including leading noise, vertical curl, easing, reversal, post-threshold drift, short flick and cancellation. Constants are pinned across languages. | | Element blur cancels a swipe | Only a blur targeted at the window resets the detector. | Element focus transfer commits; window blur and hidden-document transitions cancel. | | Off switch and failure lifecycle | Startup binds observation to the effective setting. Native setup/poll/cleanup runs on a daemon worker. Disabled sessions cancel immediately; their callbacks cannot join a replacement session. Setup failures release partial resources, and run-loop failures cancel and permit retry. | Disabled startup, queued startup cancellation, disable/re-enable, denied permission, run-loop return/exception, partial allocation, replacement, tap interruption and concurrent claim/release tests. Settings descriptions distinguish permission denial from failure, including environment-owned settings. | -| Wheel hot path and lossy companion transport | Disabled and line-mode wheels avoid host IPC. Vertical pixel samples still retain initial scroll ownership. Active publication occurs once per contact. The host retains 32 terminal records, which the companion reconciles before a new pointer event or watchdog poll can discard the old contact. | Existing nested-scroller tests retained. Publication-count and bounded-history tests cover rapid contacts; companion tests cover recovery, cancellation, missing/evicted evidence and stale watchdog ownership. | +| Wheel hot path and lossy companion transport | Disabled and line-mode wheels avoid host IPC. Vertical pixel samples still retain initial scroll ownership. Active publication occurs once per contact and carries the previous native termination cutoff. The host retains 32 terminal records, which the companion reconciles before a new pointer event or watchdog poll can discard the old contact. | Existing nested-scroller tests retained. Publication-order, cutoff and bounded-history tests cover rapid contacts; companion tests cover recovery, cancellation, missing/evicted evidence and stale watchdog ownership. | Terminal history is a bounded, non-destructive record, not an unbounded delivery queue. A consumer delayed past 32 subsequent releases cancels if its record has been evicted. Updated companion code also recognizes that these hosts cancel on failure themselves, so the old ten-second watchdog does not cancel a stationary native hold. @@ -46,7 +54,7 @@ Still to be performed on the paired host/plugin build; no new hardware validatio - Try a short flick with a large momentum tail: momentum must not supply missing finger travel. - Scroll nested horizontal content from its interior and either edge: retain ownership throughout. - Repeat on the browser home surface, including rapid successive contacts while navigation is busy. -- Compare affordance progress and release behavior for slow/fractional drags and browser zoom. Native points, CSS pixels and Compose units remain different coordinate spaces; the companion's existing calibration is preserved. -- Disable/re-enable in Settings; where applicable, verify denied Input Monitoring and recovery after granting it. +- Compare affordance progress and release behavior for slow/fractional drags and browser zoom. At non-100% zoom, explicitly check whether the native point-based vertical threshold vetoes a gesture the CSS-pixel page detector accepts; native rejection is logged for diagnosis. The companion's existing calibration is preserved. +- On a Mac that has never granted BOSS Input Monitoring, confirm BOSS appears in System Settings > Privacy & Security > Input Monitoring. The app only preflights access and does not prompt; if absent, use the pane's `+` control and select BOSS.app when that option is available. Grant access, then turn swipe navigation off and on to retry. The reviewer requested this check before merge. Keep that requirement visible rather than treating green automated tests as a substitute. diff --git a/scripts/test/swipe-nav-cases.json b/scripts/test/swipe-nav-cases.json index cdaa1adae6..afef6bd425 100644 --- a/scripts/test/swipe-nav-cases.json +++ b/scripts/test/swipe-nav-cases.json @@ -1,5 +1,6 @@ [ {"name": "leading vertical noise", "samples": [[0, 1], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0]], "expected": ["back"], "cancelled": false}, + {"name": "leading horizontal jitter", "samples": [[1, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0]], "expected": [], "cancelled": false}, {"name": "vertical curl cancelled", "samples": [[0, 80], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0]], "expected": [], "cancelled": false}, {"name": "ease below threshold", "samples": [[-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [50, 0]], "expected": [], "cancelled": false}, {"name": "reversal", "samples": [[-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [-20, 0], [150, 0]], "expected": [], "cancelled": false}, From d1bfeb848aa4e602ec5be2f7a61af7505a6e3218 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Tue, 15 Sep 2026 05:48:01 +0530 Subject: [PATCH 13/49] fix(kernel): require verified caller identity for the run-configuration bridge (#634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(overlays): honor RTL and fractional placement (#477) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and buil… * fix(kernel): require verified caller identity for the Git bridge (#635) * fix(kernel): require verified caller identity for the Git bridge GitServiceBridge had no caller-identity check on any of its fourteen RPCs. Any process able to open a connection to the kernel IPC server - not only the plugins the host itself loaded - could read the open project's full commit history and working-tree status, and could discardChanges (destroying uncommitted work), checkout, cherryPick or revert against the project's real git repository with no credential at all. Gate every RPC behind authenticatedCallerOrRefuse(), the same PERMISSION_DENIED-on-no-identity pattern PluginUIServiceBridge established for BossConsole#53. The four watch streams (file status, commit log, is-repo, is-loading) capture identity synchronously before returning and re-check it on every emission, so a revoked token stops a live stream rather than only new calls - matching the shape PR #550's review settled on for this bridge's siblings. * style(tests): wrap chained builder calls in GitServiceBridgeTest Real CI's ktlintDesktopTestSourceSetCheck flagged chain-method-continuation violations (a local run had missed this) - wrap the affected builder chains onto their own lines, per ktlintFormat. No behavior change. * test(kernel): cover revoked callers and log stream refusals --------- Co-authored-by: Shivang * fix(kernel): require verified caller identity for the Log bridge (#645) * fix(kernel): require verified caller identity for the Log bridge LogServiceBridge had no caller-identity check on any of its nine RPCs. Any process able to open a connection to the kernel IPC server - not only the plugins the host itself loaded - could watchLogs to see the live stdout/stderr stream of the running BOSS process, or call exportLogs to pull its full history, with no credential at all. Unlike a bridge whose values are deliberately routed through LogSanitizer at the call site, nothing about this stream is scrubbed for IPC exposure - it is whatever every logger.info/warn/error call site across this large and growing codebase happened to write, which is not a guarantee that none of it is a stack trace, a file path, or something else nobody meant to expose to an arbitrary local process. Gate every RPC behind authenticatedCallerOrRefuse(), the same PERMISSION_DENIED-on-no-identity pattern PluginUIServiceBridge established for BossConsole#53. The four watch streams capture identity synchronously before returning and re-check it on every emission, matching the shape PR #550's review settled on for this bridge's siblings. * test(kernel): verify revocation and diagnose stream refusals --------- Co-authored-by: Shivang * fix(security): randomize the IV for encrypt_text/decrypt_text (#647) * fix(security): randomize the IV for encrypt_text/decrypt_text encrypt_text called the 3-arg extensions.encrypt(plaintext, key, 'aes'), which pgcrypto documents as using an all-zero IV, so encryption was a pure function of (plaintext, key) alone: two equal passwords produced byte-identical ciphertext. Because create_secret encrypts server-side and RLS lets a user read their own password_encrypted back, any signed-in user was an encryption oracle - grouping a dump by ciphertext names every set of users sharing a password, with no key required. The same defect applied to recovery_codes_encrypted and twofa_secret. encrypt_text now draws a fresh random 16-byte IV per call and stores 'v2:' || base64(iv || ciphertext); decrypt_text dispatches on that prefix and falls back to the original zero-IV path for anything not yet migrated, so this deploys without a flag day. A backfill converts existing rows in all three columns, guarded by NOT LIKE 'v2:%' so a partial or repeated run is a no-op. Deliberately scoped to this one defect (BossConsole#618 names three). Defect 2 (the encryption key truncated to 32 string characters instead of 32 bytes) is not touched here: ops/rotate_master_encryption_key.sql records that a deployed key may be hex or base64, and this migration cannot tell which is live in any given project's Vault - guessing wrong would make get_encryption_key()'s result unusable and break encryption for every row on deploy. Defect 3 (no MAC/AEAD) needs its own wire-format review. ops/rotate_master_encryption_key.sql itself will need a follow-up before it can run again, since it manipulates raw ciphertext bytes assuming the old no-IV, no-prefix layout - both gaps are documented in the migration's own header. This could not be run against a real Postgres/pgTAP instance in this environment (no supabase/docker CLI available here) - the added test was written and reviewed by hand, and hosted CI's Database (pgTAP) check is the actual verification gate. * fix(security): un-qualify substring(... from ...) so it parses CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): pg_catalog.substring(x from y) is a syntax error. substring's SQL-standard FROM/FOR trailing syntax is recognized by the parser only on the bare function name - schema-qualifying it makes PostgreSQL treat it as an ordinary call, where FROM is not a valid argument separator. 20260909000000_encrypt_totp.sql's own decrypt_text already calls substring unqualified for exactly this reason; this migration's three call sites (envelope/iv/body extraction in decrypt_text, and the twofa_secret backfill) now match that convention. Both functions' search_path already resolves the unqualified name to pg_catalog's substring, so nothing else about name resolution changes. * fix(security): make key rotation understand the v2 IV envelope CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): the pre-existing master_key_rotation_test.sql runs ops/rotate_master_encryption_key.sql against real fixture data, and that script's re-encrypt loop calls extensions.decrypt/encrypt directly on raw ciphertext bytes, assuming the old zero-IV, no-prefix layout. The 'v2:' prefix the previous commit's encrypt_text now writes is not valid base64, so decoding it raised - exactly the gap this migration's own header comment already named as a follow-up, just sooner than expected. Added a pg_temp helper, rotate_ciphertext, that re-encrypts a mapped column's value under the new key while preserving whichever envelope it already carries: a 'v2:' row is decrypted with decrypt_iv under the old key and re-encrypted with encrypt_iv under the new key AND a fresh IV (a function rather than an inline SQL expression, specifically so gen_random_bytes(16) is evaluated once and reused for both the encrypt_iv call and the stored prefix - two calls would produce two different values and corrupt the row). A legacy row - every column before this pair of migrations, and still qbo_token_state / google_token_state going forward, since those are untouched by the IV migration - stays zero-IV, matching what it already was. Rotation's contract stays "same shape, new key"; upgrading a row's shape is the encryption migration's job, not rotation's. Updated the IV migration's own header comment, which had flagged this as a still-open follow-up, to point at the fix now landing alongside it instead. * fix(security): preserve plaintext bytes during IV upgrade * fix(security): make IV backfill atomic and diagnose unreadable rows --------- Co-authored-by: Shivang * fix: verify encryption backfill and address integration review * feat(search): index open tabs by their URL and file path (#606) (#648) Global Search matched an open tab only against its title, and TabResult hardcoded url = null / filePath = null on every hit. A browser tab whose title never mentions its domain, or an editor tab someone wants to find by path rather than filename, could not be found at all. searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and an editor tab's EditorTabInfo.filePath, through the same substring- containment gate (proseScore) searchRecentPages already uses for a page's URL, since a fuzzy subsequence match over a whole URL or path is close to meaningless. TabResult carries the real url/filePath instead of null, and the tab row's subtitle now leads with the workspace name (disambiguating identical URLs open in different workspaces, or across windows) followed by the url/filePath/tabType, in place of the old "tabType • workspaceName" line. 11 tests cover: URL-only and file-path-only matches, both fields populated for the right tab type and left null for the other/a blank path, a short query not matching a URL by fuzzy accident, title+URL both matching taking the higher (title) score with title highlight ranges preserved, live navigation changing which URL matches (and the old URL no longer matching), directory-path matching without a subsequence false-positive, and no-open-tabs contributing nothing rather than failing. * fix(browser): replace superseded capture requests (#509) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and buil… * fix(kernel): require verified caller identity for the active-tabs bridge (#646) Every ActiveTabsService RPC was reachable by any process able to open a connection to the kernel IPC server, not only the plugins the host itself loaded. watchActiveTabs disclosed every open tab's title and URL across every window with no credential at all, and createBrowserTab could force the browser to navigate to an arbitrary URL; selectTab/closeTab could manipulate what the user is looking at. All seven RPCs now require a verified caller identity, matching the pattern PR #505 established for the Secret Service: unary RPCs call authenticatedCallerOrRefuse, and the watchActiveTabs stream captures ProcessIdentityInterceptor.CURRENT_IDENTITY synchronously in the method body (before the flow is collected, on the calling thread the interceptor's context is current on) and re-checks it before every emission, so a revoked caller's stream is refused before an already-held handle could receive a value it should no longer see. Refusals are logged with a bind/revocation stage tag, and the three mutating RPCs (selectTab, createBrowserTab, closeTab) now record an attributed audit line - caller and RPC name only, no URLs or titles. Tested with a real gRPC server plus ProcessIdentityInterceptor: every RPC is refused with no credential and never reaches the provider, an authenticated caller can watch/read/select/create/close tabs, and a revoked caller's live watch stream terminates with PERMISSION_DENIED before receiving a value that arrived after revocation. * :arrow_up: deps:(deps): bump essenty from 2.5.0 to 2.6.0 (#655) Bumps `essenty` from 2.5.0 to 2.6.0. Updates `com.arkivanov.essenty:lifecycle` from 2.5.0 to 2.6.0 - [Release notes](https://github.com/arkivanov/Essenty/releases) - [Commits](https://github.com/arkivanov/Essenty/compare/2.5.0...2.6.0) Updates `com.arkivanov.essenty:state-keeper` from 2.5.0 to 2.6.0 - [Release notes](https://github.com/arkivanov/Essenty/releases) - [Commits](https://github.com/arkivanov/Essenty/compare/2.5.0...2.6.0) --- updated-dependencies: - dependency-name: com.arkivanov.essenty:lifecycle dependency-version: 2.6.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.arkivanov.essenty:state-keeper dependency-version: 2.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * :arrow_up: deps:(deps): bump org.graalvm.buildtools.native (#656) Bumps [org.graalvm.buildtools.native](https://github.com/graalvm/native-build-tools) from 1.1.11 to 1.1.12. - [Release notes](https://github.com/graalvm/native-build-tools/releases) - [Commits](https://github.com/graalvm/native-build-tools/compare/1.1.11...1.1.12) --- updated-dependencies: - dependency-name: org.graalvm.buildtools.native dependency-version: 1.1.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(kernel): require verified caller identity for the run-configuration bridge Every RunConfigurationService RPC was reachable by any process able to open a connection to the kernel IPC server. watchDetectedConfigurations disclosed every auto-detected run configuration with no credential, and execute was an unauthenticated arbitrary-command-execution primitive: ExecuteConfigRequest carries a full command, argument string, working directory and environment map that RunConfigurationDataProvider.execute hands straight to RunExecutionService, whose own KDoc says what that does - "opens a terminal and runs the command." All six RPCs now require a verified caller identity, matching the pattern PR #505 established for the Secret Service. execute does not run what the caller sent: it takes only the requested id, looks up the matching entry in the provider's own detectedConfigurations, and executes that provider-held copy - so a request naming a real id but carrying a tampered command, or naming an id outside the provider's current set (including a blank id, or one from before a rescan), is refused with PERMISSION_DENIED before RunExecutionService ever sees it. The client-side proxy (RunConfigDataProviderProxy) previously caught every exception from a refused execute and discarded it silently, so a Run click during or just after a rescan resolved to nothing on either side - no terminal, no error, only a host-log line nobody was looking at. execute now surfaces a client-visible refusal through lastError, the one signal RunConfigurationDataProvider has and that the run panel already renders, kept separate from the host's own scan-error channel so the two lifetimes don't clobber each other: a local execution error clears on a successful retry or an explicit clearError(), a host error is preserved underneath it and restored once the local one clears, and the message stays generic rather than claiming a specific cause (stale id vs. a revoked token vs. a transport fault) it cannot actually distinguish safely. Cancellation now propagates untouched from all three RPCs rather than being swallowed by a blanket catch. Tested with a real gRPC server against the bridge (8 cases: identity gating on all six RPCs, tamper resistance, unknown/blank id refusal, unary and mid-stream revocation, refusal during and after a rescan) and against the proxy with a fake service (9 cases: refused vs. successful execute, host/local error independence, dismiss and retry clearing, transport failure not claiming a stale id, and cancellation propagating from all three RPCs without publishing a spurious error). * Fix scan error precedence and strengthen run proxy regressions --------- Signed-off-by: dependabot[bot] Co-authored-by: Shikhar Goel Co-authored-by: Nilesh Vasita Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Shivang Co-authored-by: Shivanshu Co-authored-by: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Co-authored-by: Patel Karma Rohit Co-authored-by: Aditya8369 Co-authored-by: passionate-coder26 Co-authored-by: rehannayeem0786 Co-authored-by: rehannayeem0786 <128595058+rehannayeem0786@users.noreply.github.com> Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shwetas Dhake Co-authored-by: AdityaK-iiita Co-authored-by: Oleksii (Lexi) Kyrylchuk Co-authored-by: Sahvendra Co-authored-by: Aishwary Anand <201525941+Aishwary11-11@users.noreply.github.com> Co-authored-by: Aishwary Anand Co-authored-by: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Co-authored-by: ayush Co-authored-by: Anshul Pundhir <129892939+pundhiranshul@users.noreply.github.com> Co-authored-by: Sanjay Co-authored-by: arjun28115 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Achintya Chaurasia Co-authored-by: surfthewave-commits Co-authored-by: Ayushman <139611211+InfoSage05@users.noreply.github.com> Co-authored-by: Kanak Khandelwal <152528927+Kanakk23@users.noreply.github.com> Co-authored-by: Ishaan Chowdhury Co-authored-by: Karan Raj Co-authored-by: john_cybersage Co-authored-by: aryanbajaj868 <117334799+aryanbajaj868@users.noreply.github.com> Co-authored-by: Risa Labs Co-authored-by: john k Co-authored-by: kshivang Co-authored-by: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Co-authored-by: pundhiranshul Co-authored-by: Jaspreet Singh --- .../kernel/services/RunConfigServiceBridge.kt | 162 +++++++-- .../services/RunConfigServiceBridgeTest.kt | 333 +++++++++++++++++ .../plugin/ipc/RunConfigDataProviderProxy.kt | 36 +- .../ipc/RunConfigDataProviderProxyTest.kt | 335 ++++++++++++++++++ 4 files changed, 827 insertions(+), 39 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridgeTest.kt create mode 100644 plugin-platform/plugin-api-ipc/src/desktopTest/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxyTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridge.kt index 0d38db775f..12e6c7d686 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridge.kt @@ -1,20 +1,59 @@ 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.LanguageData import ai.rever.boss.plugin.api.RunConfigurationData import ai.rever.boss.plugin.api.RunConfigurationDataProvider import ai.rever.boss.plugin.api.RunConfigurationTypeData +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +/** + * Kernel-side bridge for `RunConfigurationService`. + * + * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and + * helper shape introduced for the Secret Service in PR #505. Stream revocation is checked + * before each emission; an idle revoked stream is not proactively disconnected. + * + * [execute] gets a second, narrower guard on top, and it is the sharper one: unlike the other + * bridges this pattern has closed so far, [ExecuteConfigRequest] does not name something to read + * or open - it carries a full `command`, `arguments`, `working_directory` and + * `environment_variables` that [RunConfigurationDataProvider.execute] hands straight to + * [ai.rever.boss.run.RunExecutionService], whose own KDoc says what that does: "Opens a terminal + * and runs the command." Before this bridge checked identity at all, `execute` was an + * unauthenticated arbitrary-command-execution primitive - not "leaks a file" or "confuses a + * dialog," but a caller naming any binary and argument vector it likes and having BOSS run it. + * Requiring identity alone is not enough to close that, because an authenticated call still + * carries the full command in its own request rather than a reference to one BOSS already knows + * about. So [execute] does not run what the caller sent: it takes only the requested id and looks + * up the matching entry in [RunConfigurationDataProvider.detectedConfigurations] - the + * auto-detected configurations the project scan produced (operator-saved configurations + * are not exposed by this provider) - and executes that provider-held copy. A request naming an id outside that set is + * refused before [RunExecutionService] ever sees it, and every other field in the request + * (command, arguments, working directory, environment) is discarded rather than trusted, so + * tampering with them while reusing a real id buys nothing. + */ +// One method per RPC the generated service base class declares, plus two small private helpers. +@Suppress("TooManyFunctions") class RunConfigServiceBridge( private val provider: RunConfigurationDataProvider, ) : RunConfigurationServiceGrpcKt.RunConfigurationServiceCoroutineImplBase() { - override fun watchDetectedConfigurations(request: Empty): Flow = - flow { + override fun watchDetectedConfigurations(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchDetectedConfigurations") provider.detectedConfigurations.collect { configs -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchDetectedConfigurations") + } emit( RunConfigListResponse .newBuilder() @@ -23,37 +62,108 @@ class RunConfigServiceBridge( ) } } + } - override fun watchIsScanning(request: Empty): Flow = - flow { + override fun watchIsScanning(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchIsScanning") provider.isScanning.collect { scanning -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchIsScanning") + } emit(RunConfigBoolResponse.newBuilder().setValue(scanning).build()) } } + } - override fun watchLastError(request: Empty): Flow = - flow { + override fun watchLastError(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchLastError") provider.lastError.collect { error -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchLastError") + } emit(RunConfigStringResponse.newBuilder().setValue(error ?: "").build()) } } + } override suspend fun scanProject(request: ScanProjectRequest): Empty { + val caller = authenticatedCallerOrRefuse("scanProject") + logger.info(LogCategory.AUTH, "Run configuration scan requested", mapOf("caller" to caller)) provider.scanProject(request.projectPath, request.windowId) return Empty.getDefaultInstance() } override suspend fun execute(request: ExecuteConfigRequest): Empty { - val config = request.configuration.toData() + val caller = authenticatedCallerOrRefuse("execute") + val config = knownConfigurationOrRefuse(caller, request.configuration.id) + logger.info( + LogCategory.AUTH, + "Run configuration execution requested", + mapOf("caller" to caller, "configurationId" to config.id), + ) provider.execute(config, request.windowId) return Empty.getDefaultInstance() } override suspend fun clearError(request: Empty): Empty { + authenticatedCallerOrRefuse("clearError") provider.clearError() return Empty.getDefaultInstance() } + /** + * Resolves [requestedId] against [RunConfigurationDataProvider.detectedConfigurations] and + * returns the provider's own copy - never the caller's. See the class KDoc for why [execute] + * cannot trust anything in the request beyond this id. Rescans clear the list and mint new + * ids; callers must use ids from watchDetectedConfigurations after the scan. An old id is + * refused, using PERMISSION_DENIED consistently with the Download bridge allowlist. + */ + private fun knownConfigurationOrRefuse( + caller: String, + requestedId: String, + ): RunConfigurationData { + val known = + requestedId.takeIf { it.isNotBlank() }?.let { id -> + provider.detectedConfigurations.value.find { it.id == id } + } + if (known == null) { + logger.warn( + LogCategory.AUTH, + "Refused execute: id is not a configuration this provider currently knows about", + mapOf("caller" to caller, "isScanning" to provider.isScanning.value), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NOT_A_KNOWN_CONFIGURATION)) + } + return known + } + + /** + * Unary RPCs only: the verified per-call identity, or `PERMISSION_DENIED`. + * Streams must capture CURRENT_IDENTITY synchronously and recheck it before each emission. + * + * 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 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 fun RunConfigurationData.toProto(): RunConfigurationProto = RunConfigurationProto .newBuilder() @@ -87,35 +197,11 @@ class RunConfigServiceBridge( .setTimestamp(timestamp) .build() - private fun RunConfigurationProto.toData(): RunConfigurationData = - RunConfigurationData( - id = id, - name = name, - type = - when (type) { - RunConfigType.RUN_CONFIG_TYPE_MAIN_FUNCTION -> RunConfigurationTypeData.MAIN_FUNCTION - RunConfigType.RUN_CONFIG_TYPE_SCRIPT -> RunConfigurationTypeData.SCRIPT - RunConfigType.RUN_CONFIG_TYPE_TEST -> RunConfigurationTypeData.TEST - else -> RunConfigurationTypeData.CUSTOM - }, - filePath = filePath, - lineNumber = lineNumber, - language = - when (language) { - RunConfigLanguage.RUN_CONFIG_LANGUAGE_KOTLIN -> LanguageData.KOTLIN - RunConfigLanguage.RUN_CONFIG_LANGUAGE_JAVA -> LanguageData.JAVA - RunConfigLanguage.RUN_CONFIG_LANGUAGE_PYTHON -> LanguageData.PYTHON - RunConfigLanguage.RUN_CONFIG_LANGUAGE_JAVASCRIPT -> LanguageData.JAVASCRIPT - RunConfigLanguage.RUN_CONFIG_LANGUAGE_TYPESCRIPT -> LanguageData.TYPESCRIPT - RunConfigLanguage.RUN_CONFIG_LANGUAGE_GO -> LanguageData.GO - RunConfigLanguage.RUN_CONFIG_LANGUAGE_RUST -> LanguageData.RUST - else -> LanguageData.UNKNOWN - }, - command = command, - workingDirectory = workingDirectory, - environmentVariables = environmentVariablesMap, - arguments = arguments, - isAutoDetected = isAutoDetected, - timestamp = timestamp, - ) + private companion object { + val logger = BossLogger.forComponent("RunConfigServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + const val NOT_A_KNOWN_CONFIGURATION = + "This id is not a run configuration this provider currently knows about" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridgeTest.kt new file mode 100644 index 0000000000..b931b233b1 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridgeTest.kt @@ -0,0 +1,333 @@ +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.ExecuteConfigRequest +import ai.rever.boss.ipc.proto.services.RunConfigType +import ai.rever.boss.ipc.proto.services.RunConfigurationProto +import ai.rever.boss.ipc.proto.services.RunConfigurationServiceGrpcKt +import ai.rever.boss.ipc.proto.services.ScanProjectRequest +import ai.rever.boss.plugin.api.LanguageData +import ai.rever.boss.plugin.api.RunConfigurationData +import ai.rever.boss.plugin.api.RunConfigurationDataProvider +import ai.rever.boss.plugin.api.RunConfigurationTypeData +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withTimeout +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The run-configuration bridge, exercised over a real gRPC server (BossConsole#53). + * + * `Execute` is the sharpest RPC on this server: [ExecuteConfigRequest] carries a full command, + * argument string, working directory and environment map that reach + * [ai.rever.boss.run.RunExecutionService] - "opens a terminal and runs the command," per that + * class's own KDoc. Requiring a verified caller is necessary but not sufficient here, because the + * dangerous fields travel in the request itself rather than as a path or id into something the + * host already trusts. So the test that matters most below is not "an anonymous caller is + * refused" (every bridge has that) but "an authenticated caller cannot smuggle an arbitrary + * command past a real configuration's id" - proving the bridge executes its own stored copy of a + * known configuration, never the caller's. + */ +class RunConfigServiceBridgeTest { + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var provider: FakeRunConfigurationDataProvider + private lateinit var server: Server + private lateinit var authenticatedChannel: ManagedChannel + private lateinit var anonymousChannel: ManagedChannel + private lateinit var authenticated: RunConfigurationServiceGrpcKt.RunConfigurationServiceCoroutineStub + private lateinit var anonymous: RunConfigurationServiceGrpcKt.RunConfigurationServiceCoroutineStub + + @BeforeTest + fun setUp() { + tokenRegistry = ProcessTokenRegistry() + provider = FakeRunConfigurationDataProvider() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(RunConfigServiceBridge(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 = RunConfigurationServiceGrpcKt.RunConfigurationServiceCoroutineStub(authenticatedChannel) + anonymous = RunConfigurationServiceGrpcKt.RunConfigurationServiceCoroutineStub(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 { + assertFailsWithPermissionDenied { + anonymous.watchDetectedConfigurations(Empty.getDefaultInstance()).take(1).toList() + } + assertFailsWithPermissionDenied { anonymous.watchIsScanning(Empty.getDefaultInstance()).take(1).toList() } + assertFailsWithPermissionDenied { anonymous.watchLastError(Empty.getDefaultInstance()).take(1).toList() } + assertFailsWithPermissionDenied { + anonymous.scanProject(ScanProjectRequest.newBuilder().setProjectPath("/tmp").build()) + } + assertFailsWithPermissionDenied { + anonymous.execute(ExecuteConfigRequest.newBuilder().setConfiguration(protoConfig("known-1")).build()) + } + assertFailsWithPermissionDenied { anonymous.clearError(Empty.getDefaultInstance()) } + + assertTrue(provider.calls.isEmpty(), "a refused call must never reach the provider") + } + + @Test + fun `an authenticated caller can watch, scan and clear errors`() = + runBlocking { + provider.setConfigurations(listOf(knownConfig("known-1"))) + + val configs = + authenticated + .watchDetectedConfigurations(Empty.getDefaultInstance()) + .take(1) + .toList() + .single() + assertEquals(1, configs.configurationsCount) + assertEquals("known-1", configs.configurationsList.single().id) + + authenticated.scanProject( + ScanProjectRequest + .newBuilder() + .setProjectPath("/repo") + .setWindowId("w1") + .build(), + ) + authenticated.clearError(Empty.getDefaultInstance()) + + assertEquals(listOf("scanProject", "clearError"), provider.calls) + assertEquals("/repo" to "w1", provider.scanned.single()) + } + + @Test + fun `execute runs the provider's own copy of a known configuration, not the caller's`() = + runBlocking { + provider.setConfigurations(listOf(knownConfig("known-1", command = "echo", arguments = "safe"))) + + // The request names a real id but carries a completely different, attacker-chosen + // command - if the bridge trusted this payload, this is the exploit. + val tampered = protoConfig("known-1", command = "rm", arguments = "-rf /") + authenticated.execute( + ExecuteConfigRequest + .newBuilder() + .setConfiguration(tampered) + .setWindowId("w1") + .build(), + ) + + assertEquals("w1", provider.executionWindows.single()) + val executed = provider.executed.single() + assertEquals("echo", executed.command, "must execute the provider's stored command") + assertEquals("safe", executed.arguments, "must execute the provider's stored arguments") + } + + @Test + fun `execute with an id the provider does not know is refused and never runs anything`() = + runBlocking { + provider.setConfigurations(listOf(knownConfig("known-1"))) + + val failure = + assertFailsWith { + authenticated.execute( + ExecuteConfigRequest + .newBuilder() + .setConfiguration(protoConfig("not-a-real-id", command = "rm", arguments = "-rf /")) + .setWindowId("w1") + .build(), + ) + } + + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(provider.executed.isEmpty(), "an unknown id must never reach RunExecutionService") + } + + @Test + fun `revoked credentials cannot invoke a unary operation`() = + runBlocking { + tokenRegistry.revoke(CALLER) + val failure = assertFailsWith { authenticated.clearError(Empty.getDefaultInstance()) } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `revoked watcher cannot receive a later snapshot`() = + runBlocking { + withTimeout(10_000) { + supervisorScope { + val received = Channel(Channel.UNLIMITED) + val watching = + async { + authenticated.watchIsScanning(Empty.getDefaultInstance()).collect { received.send(Unit) } + } + try { + received.receive() + tokenRegistry.revoke(CALLER) + provider.setScanning(true) + val failure = assertFailsWith { watching.await() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(received.tryReceive().isFailure, "revocation must prevent the next snapshot") + } finally { + watching.cancel() + received.close() + } + } + } + } + + @Test + fun `blank ids never authorize a provider configuration`() = + runBlocking { + provider.setConfigurations(listOf(knownConfig(""), knownConfig(" "))) + assertFailsWithPermissionDenied { authenticated.execute(ExecuteConfigRequest.getDefaultInstance()) } + assertFailsWithPermissionDenied { + authenticated.execute(ExecuteConfigRequest.newBuilder().setConfiguration(protoConfig(" ")).build()) + } + assertTrue(provider.executed.isEmpty()) + } + + @Test + fun `an observed id is refused during and after a rescan`() = + runBlocking { + provider.setConfigurations(listOf(knownConfig("before-scan"))) + val observed = + authenticated + .watchDetectedConfigurations(Empty.getDefaultInstance()) + .take(1) + .toList() + .single() + val request = + ExecuteConfigRequest.newBuilder().setConfiguration(observed.configurationsList.single()).build() + provider.setScanning(true) + provider.setConfigurations(emptyList()) + assertFailsWithPermissionDenied { authenticated.execute(request) } + provider.setConfigurations(listOf(knownConfig("after-scan"))) + provider.setScanning(false) + assertFailsWithPermissionDenied { authenticated.execute(request) } + assertTrue(provider.executed.isEmpty(), "a rescan must never select a replacement execution silently") + } + + private suspend fun assertFailsWithPermissionDenied(call: suspend () -> Unit) { + val failure = assertFailsWith { call() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + + private fun knownConfig( + id: String, + command: String = "kotlin", + arguments: String = "", + ): RunConfigurationData = + RunConfigurationData( + id = id, + name = id, + type = RunConfigurationTypeData.MAIN_FUNCTION, + filePath = "/repo/Main.kt", + lineNumber = 1, + language = LanguageData.KOTLIN, + command = command, + workingDirectory = "/repo", + environmentVariables = emptyMap(), + arguments = arguments, + isAutoDetected = true, + timestamp = 0L, + ) + + private fun protoConfig( + id: String, + command: String = "kotlin", + arguments: String = "", + ): RunConfigurationProto = + RunConfigurationProto + .newBuilder() + .setId(id) + .setName(id) + .setType(RunConfigType.RUN_CONFIG_TYPE_MAIN_FUNCTION) + .setCommand(command) + .setArguments(arguments) + .build() + + /** Records every method it was actually asked to perform, so a refusal can be proven silent. */ + private class FakeRunConfigurationDataProvider : RunConfigurationDataProvider { + val calls = mutableListOf() + val executed = mutableListOf() + val scanned = mutableListOf>() + val executionWindows = mutableListOf() + private val _detectedConfigurations = MutableStateFlow>(emptyList()) + override val detectedConfigurations: StateFlow> = _detectedConfigurations + private val _isScanning = MutableStateFlow(false) + override val isScanning: StateFlow = _isScanning + private val _lastError = MutableStateFlow(null) + override val lastError: StateFlow = _lastError + + fun setScanning(scanning: Boolean) { + _isScanning.value = scanning + } + + fun setConfigurations(configs: List) { + _detectedConfigurations.value = configs + } + + override suspend fun scanProject( + projectPath: String, + windowId: String, + ) { + calls += "scanProject" + scanned += projectPath to windowId + } + + override suspend fun execute( + config: RunConfigurationData, + windowId: String, + ) { + calls += "execute" + executed += config + executionWindows += windowId + } + + override suspend fun clearError() { + calls += "clearError" + } + } + + private companion object { + const val CALLER = "run-panel-plugin" + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +} diff --git a/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt b/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt index 08577b0756..47d8cf712f 100644 --- a/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt +++ b/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt @@ -35,6 +35,11 @@ class RunConfigDataProviderProxy( private val _lastError = MutableStateFlow(null) override val lastError: StateFlow = _lastError.asStateFlow() + // Host scan errors and local run failures have independent lifetimes. + private val errorLock = Any() + private var localExecutionError: String? = null + private var hostError: String? = null + init { scope.launch { watchConfigurations() } scope.launch { watchIsScanning() } @@ -84,7 +89,14 @@ class RunConfigDataProviderProxy( while (scope.isActive) { try { stub.watchLastError(Empty.getDefaultInstance()).collect { response -> - _lastError.value = response.value.takeIf { it.isNotEmpty() } + synchronized(errorLock) { + val incomingError = response.value.takeIf { it.isNotEmpty() } + // A new scan failure supersedes run feedback; a replay of the same + // host snapshot must not erase a newer local execution failure. + if (incomingError != null && incomingError != hostError) localExecutionError = null + hostError = incomingError + _lastError.value = localExecutionError ?: hostError + } } delayMs = 1_000L } catch ( @@ -102,6 +114,7 @@ class RunConfigDataProviderProxy( projectPath: String, windowId: String, ) { + clearLocalExecutionError() try { stub.scanProject( ScanProjectRequest @@ -110,6 +123,8 @@ class RunConfigDataProviderProxy( .setWindowId(windowId) .build(), ) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (_: Exception) { } } @@ -126,17 +141,36 @@ class RunConfigDataProviderProxy( .setWindowId(windowId) .build(), ) + clearLocalExecutionError() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (_: Exception) { + // Do not infer a stale id from every RPC failure (transport and authorization + // failures can reach this path too), or expose arbitrary remote exception text. + synchronized(errorLock) { + localExecutionError = "Run could not be started." + _lastError.value = localExecutionError + } } } override suspend fun clearError() { + clearLocalExecutionError() try { stub.clearError(Empty.getDefaultInstance()) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (_: Exception) { } } + private fun clearLocalExecutionError() { + synchronized(errorLock) { + localExecutionError = null + _lastError.value = hostError + } + } + private fun RunConfigurationProto.toData() = RunConfigurationData( id = id, diff --git a/plugin-platform/plugin-api-ipc/src/desktopTest/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxyTest.kt b/plugin-platform/plugin-api-ipc/src/desktopTest/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxyTest.kt new file mode 100644 index 0000000000..6b136a7241 --- /dev/null +++ b/plugin-platform/plugin-api-ipc/src/desktopTest/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxyTest.kt @@ -0,0 +1,335 @@ +package ai.rever.boss.plugin.ipc + +import ai.rever.boss.ipc.proto.Empty +import ai.rever.boss.ipc.proto.services.ExecuteConfigRequest +import ai.rever.boss.ipc.proto.services.RunConfigBoolResponse +import ai.rever.boss.ipc.proto.services.RunConfigListResponse +import ai.rever.boss.ipc.proto.services.RunConfigStringResponse +import ai.rever.boss.ipc.proto.services.RunConfigurationServiceGrpcKt.RunConfigurationServiceCoroutineImplBase +import ai.rever.boss.ipc.proto.services.ScanProjectRequest +import ai.rever.boss.plugin.api.LanguageData +import ai.rever.boss.plugin.api.RunConfigurationData +import ai.rever.boss.plugin.api.RunConfigurationTypeData +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.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * The client half of BossConsole#634's stale-Run finding: `RunConfigServiceBridge` correctly + * refuses an `Execute` naming an id it no longer holds (proven server-side by + * `RunConfigServiceBridgeTest`), but the proxy caught every exception from that refusal and + * discarded it - so a Run click across a rescan opened no terminal and told the operator nothing. + * `lastError` is the only client-visible signal this pinned interface has (the run panel already + * renders it), so this is what a refused `execute` now sets it to. + * + * The fake server here doesn't check identity at all - that boundary belongs to + * `RunConfigServiceBridgeTest`, which runs against the real bridge. This test is only about what + * the proxy does with a `StatusException` it receives, so the fake refuses or accepts by id alone. + */ +class RunConfigDataProviderProxyTest { + private lateinit var server: Server + private lateinit var channel: ManagedChannel + private lateinit var proxy: RunConfigDataProviderProxy + private lateinit var proxyScope: CoroutineScope + private lateinit var fakeService: FakeRunConfigurationService + + @BeforeTest + fun setUp() { + fakeService = FakeRunConfigurationService() + server = + ServerBuilder + .forPort(0) + .addService(fakeService) + .build() + .start() + channel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + proxyScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + proxy = RunConfigDataProviderProxy(channel, proxyScope) + } + + @AfterTest + fun tearDown() { + proxyScope.cancel() + channel.shutdownNow() + channel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + @Test + fun `a refused execute becomes a visible client-side error, not silence`() = + runBlocking { + withTimeout(10_000) { + assertNull(proxy.lastError.value, "no error before the call") + + proxy.execute(config("stale-id"), "w1") + + assertNotNull(proxy.lastError.value, "a refused execute must surface something to the run panel") + assertEquals(1, fakeService.executeAttempts.get(), "the request did reach the server and was refused") + } + } + + @Test + fun `a successful execute without errors keeps lastError empty`() = + runBlocking { + withTimeout(10_000) { + proxy.execute(config(FakeRunConfigurationService.ACCEPTED_ID), "w1") + + assertNull(proxy.lastError.value, "a successful execute must not report a stale-run error") + } + } + + @Test + fun `host empty snapshots do not erase a local refusal and clear dismisses it`() = + runBlocking { + withTimeout(10_000) { + proxy.execute(config("stale-id"), "w1") + fakeService.errors.send("") + assertNull( + withTimeoutOrNull(300) { + proxy.lastError.first { it == null } + true + }, + ) + assertNotNull(proxy.lastError.value) + proxy.clearError() + assertNull(proxy.lastError.value) + } + } + + @Test + fun `successful retry clears the local refusal`() = + runBlocking { + withTimeout(10_000) { + proxy.execute(config("stale-id"), "w1") + assertNotNull(proxy.lastError.value) + proxy.execute(config(FakeRunConfigurationService.ACCEPTED_ID), "w1") + assertNull(proxy.lastError.value) + } + } + + @Test + fun `clearing a local error preserves the latest host error`() = + runBlocking { + withTimeout(10_000) { + fakeService.errors.send("host failure") + proxy.lastError.first { it == "host failure" } + proxy.execute(config("stale-id"), "w1") + proxy.clearError() + assertEquals("host failure", proxy.lastError.value) + } + } + + @Test + fun `transport failure reports failure without claiming a stale id`() = + runBlocking { + withTimeout(10_000) { + proxy.execute(config("stale-id"), "w1") + val refusal = proxy.lastError.value + proxy.execute(config("offline"), "w1") + val error = assertNotNull(proxy.lastError.value) + assertEquals(refusal, error) + assertFalse(error.contains("remote details")) + } + } + + @Test + fun `cancellation propagates without publishing an execution error`() = + runBlocking { + withTimeout(10_000) { + val executing = async { proxy.execute(config("blocked"), "w1") } + fakeService.blocked.await() + executing.cancel() + assertFailsWith { executing.await() } + assertNull(proxy.lastError.value) + } + } + + @Test + fun `scan cancellation propagates`() = + runBlocking { + withTimeout(10_000) { + fakeService.blockOperation = "scan" + val continued = AtomicBoolean(false) + val calling = + async { + proxy.scanProject("/repo", "w1") + continued.set(true) + } + fakeService.blocked.await() + calling.cancel() + assertFailsWith { calling.await() } + calling.join() + assertFalse(continued.get(), "cancellation must not return normally from the proxy call") + assertNull(proxy.lastError.value) + } + } + + @Test + fun `clear cancellation propagates`() = + runBlocking { + withTimeout(10_000) { + fakeService.blockOperation = "clear" + val continued = AtomicBoolean(false) + val calling = + async { + proxy.clearError() + continued.set(true) + } + fakeService.blocked.await() + calling.cancel() + assertFailsWith { calling.await() } + calling.join() + assertFalse(continued.get(), "cancellation must not return normally from the proxy call") + assertNull(proxy.lastError.value) + } + } + + @Test + fun `a new host scan failure supersedes a local run failure`() = + runBlocking { + withTimeout(10_000) { + proxy.execute(config("stale-id"), "w1") + fakeService.errors.send("new scan failure") + assertEquals("new scan failure", proxy.lastError.first { it == "new scan failure" }) + } + } + + @Test + fun `a successful run preserves a pending host error`() = + runBlocking { + withTimeout(10_000) { + fakeService.errors.send("host failure") + proxy.lastError.first { it == "host failure" } + proxy.execute(config(FakeRunConfigurationService.ACCEPTED_ID), "w1") + assertEquals("host failure", proxy.lastError.value) + } + } + + @Test + fun `a new scan clears local run feedback even when the host error is unchanged`() = + runBlocking { + withTimeout(10_000) { + fakeService.errors.send("host failure") + proxy.lastError.first { it == "host failure" } + proxy.execute(config("stale-id"), "w1") + proxy.scanProject("/repo", "w1") + assertEquals("host failure", proxy.lastError.value) + } + } + + private fun config(id: String): RunConfigurationData = + RunConfigurationData( + id = id, + name = id, + type = RunConfigurationTypeData.MAIN_FUNCTION, + filePath = "/repo/Main.kt", + lineNumber = 1, + language = LanguageData.KOTLIN, + command = "kotlin", + workingDirectory = "/repo", + environmentVariables = emptyMap(), + arguments = "", + isAutoDetected = true, + timestamp = 0L, + ) + + /** + * Refuses every id except [ACCEPTED_ID], mirroring `RunConfigServiceBridge`'s allowlist + * refusal shape (`PERMISSION_DENIED`) without needing the real bridge's identity gating - + * that boundary is `RunConfigServiceBridgeTest`'s to cover. + */ + private class FakeRunConfigurationService : RunConfigurationServiceCoroutineImplBase() { + val executeAttempts = AtomicInteger(0) + val blocked = CompletableDeferred() + var blockOperation: String? = null + val errors = Channel(Channel.RENDEZVOUS) + + override fun watchDetectedConfigurations(request: Empty): Flow = neverEmits() + + override fun watchIsScanning(request: Empty): Flow = neverEmits() + + override fun watchLastError(request: Empty): Flow = + flow { + for (error in errors) { + emit(RunConfigStringResponse.newBuilder().setValue(error).build()) + } + } + + /** + * Never emits: the three watch RPCs exist only so the proxy's background watcher + * coroutines, started from its `init` block, have something to suspend on instead of + * hot-looping against a stream the fake never serves. + */ + private fun neverEmits(): Flow = flow { awaitCancellation() } + + override suspend fun scanProject(request: ScanProjectRequest): Empty { + blockIfRequested("scan") + return Empty.getDefaultInstance() + } + + override suspend fun execute(request: ExecuteConfigRequest): Empty { + executeAttempts.incrementAndGet() + if (request.configuration.id == "blocked") { + blocked.complete(Unit) + awaitCancellation() + } + if (request.configuration.id == "offline") { + throw StatusException(Status.UNAVAILABLE.withDescription("remote details")) + } + if (request.configuration.id != ACCEPTED_ID) { + throw StatusException(Status.PERMISSION_DENIED.withDescription("stale id")) + } + return Empty.getDefaultInstance() + } + + override suspend fun clearError(request: Empty): Empty { + blockIfRequested("clear") + return Empty.getDefaultInstance() + } + + private suspend fun blockIfRequested(operation: String) { + if (blockOperation == operation) { + blocked.complete(Unit) + awaitCancellation() + } + } + + companion object { + const val ACCEPTED_ID = "known-1" + } + } + + private companion object { + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +} From 1f00daa3cbdb6bf7c0bce7b28110c1c98aa3e673 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Tue, 15 Sep 2026 05:58:55 +0530 Subject: [PATCH 14/49] fix(secrets): make the secret listing RPCs fail soft on one bad row (#668) * fix(secrets): make the secret listing RPCs fail soft on one bad row BossConsole#620: get_user_secrets, search_user_secrets and get_user_secrets_with_shared each decrypt password_encrypted and recovery_codes_encrypted with a bare call to decrypt_text inside a set-returning query. decrypt_text raises on an undecryptable value - a wrong key after a partial key rotation, storage corruption, or an HMAC/padding mismatch - and one raised row aborts the whole query, so the caller sees every one of their secrets fail to load rather than just the bad one. twofa_secret already reads through safe_decrypt_twofa_secret (NULL on failure). recovery_codes_encrypted already had a safe wrapper too - safe_decrypt_recovery_codes, whose own doc comment says "used internally by get_user_secrets functions" - but whichever later migration touched these RPCs stopped calling it and fell back to raw decrypt_text, so it sat unused. password_encrypted never had a safe wrapper at all. Adds try_decrypt_text(text), mirroring safe_decrypt_recovery_codes's BEGIN/EXCEPTION WHEN OTHERS THEN RETURN NULL shape for a scalar result, locked down the same way safe_decrypt_twofa_secret is (no client role gets EXECUTE). Routes the password read in all three RPCs through it and the recovery-codes read back through the existing safe wrapper. One corrupt row now surfaces as a blanked field - password NULL, recovery_codes [] - while every other secret in the page still loads. Deliberately not named safe_decrypt_*: ops/rotate_master_encryption_key.sql's re-encrypt loop treats every function matching that name pattern as a mapped bespoke-format adapter and refuses to run if one is unmapped. try_decrypt_text wraps the same plain envelope that script already re-encrypts directly, so it must stay outside that sweep. Scoped to the three user-facing listing RPCs only; the operator-run backfill/rotation paths keep using raw decrypt_text and should still fail closed. * Fix installed-client compatibility and preserve systemic decryption failures Includes desktop regression coverage from #621 by Rushikeshiitb and review repairs by kshivang; related independent #652 work by akashjadon04 informed the review. --------- Co-authored-by: Shivang --- .../services/supabase/SecretDecodingTest.kt | 56 ++++ supabase/audit/identity_disclosure_audit.sql | 2 +- ...00_fail_soft_secret_listing_decryption.sql | 272 ++++++++++++++++++ .../explicit_anon_and_org_visibility_test.sql | 2 +- ...il_soft_secret_listing_decryption_test.sql | 99 +++++++ 5 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/20260914010000_fail_soft_secret_listing_decryption.sql create mode 100644 supabase/tests/fail_soft_secret_listing_decryption_test.sql diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt index f25582f5f4..6e0d1400cf 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt @@ -3,6 +3,7 @@ package ai.rever.boss.services.supabase import ai.rever.boss.services.supabase.models.SecretEntry import ai.rever.boss.services.supabase.models.SecretEntryWithSharing import ai.rever.boss.services.supabase.models.SecretShareEntry +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.JsonPrimitive @@ -12,6 +13,7 @@ import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull @@ -35,6 +37,60 @@ import kotlin.test.assertNull * pinning is that the services are wired to it and stay so. */ class SecretDecodingTest { + @Test + fun `blanked corrupt passwords preserve every row in both listing models`() { + val payload = + buildJsonArray { + for (id in listOf("good", "corrupt")) { + add( + buildJsonObject { + baseSecret(id) + put("password", if (id == "corrupt") "" else "readable") + put("is_owner", true) + put("access_level", "owner") + }, + ) + } + } + + val plain = supabaseJson.decodeFromJsonElement>(payload) + val shared = supabaseJson.decodeFromJsonElement>(payload) + assertEquals(listOf("good", "corrupt"), plain.map { it.id }) + assertEquals(listOf("readable", ""), plain.map { it.password }) + assertEquals(listOf("good", "corrupt"), shared.map { it.id }) + assertEquals(listOf("readable", ""), shared.map { it.password }) + } + + @Test + fun `a null password fails the whole decode - which is why the listing RPCs COALESCE it to empty`() { + // The load-bearing counterpart to the empty-string case above: "" decodes whether or not + // the SQL COALESCE exists, so on its own it proves nothing about the fix. This pins WHY the + // COALESCE must be there. SecretEntry.password and SecretEntryWithSharing.password are + // non-null String with no default, and supabaseJson's coerceInputValues coerces a null only + // into a property's default - a required field without one still throws. Because these RPCs + // return LISTS, that throw is all-or-nothing and takes the WHOLE page, which is exactly the + // outage this PR removes. So try_decrypt_text's NULL is COALESCEd to '' in every listing RPC + // (migration 20260914010000) before it can ever reach this decoder. + val payload = + buildJsonArray { + add( + buildJsonObject { + baseSecret("corrupt") + put("password", null as String?) + put("is_owner", true) + put("access_level", "owner") + }, + ) + } + + assertFailsWith { + supabaseJson.decodeFromJsonElement>(payload) + } + assertFailsWith { + supabaseJson.decodeFromJsonElement>(payload) + } + } + /** The ten columns every secret RPC returned before the organisation work. */ private fun JsonObjectBuilder.baseSecret(id: String) { put("id", id) diff --git a/supabase/audit/identity_disclosure_audit.sql b/supabase/audit/identity_disclosure_audit.sql index ba0dd672e5..bce6f3878a 100644 --- a/supabase/audit/identity_disclosure_audit.sql +++ b/supabase/audit/identity_disclosure_audit.sql @@ -222,7 +222,7 @@ select 'CHECK 5: client access to internal-only routines', coalesce(string_agg(signature || ' (' || role_name || ')' || case when to_regprocedure(signature) is null then ' MISSING SIGNATURE' else '' end, ', '), 'HEALTHY') from unnest(array['public.get_encryption_key()', 'public.encrypt_text(text)', - 'public.decrypt_text(text)', 'public.safe_decrypt_recovery_codes(text)', + 'public.decrypt_text(text)', 'public.try_decrypt_text(text)', 'public.safe_decrypt_recovery_codes(text)', 'public.upsert_plugin_rating(uuid,uuid,integer,text)', 'public.record_plugin_download(uuid,uuid,uuid,text)', 'public.custom_access_token_hook(jsonb)']) signature diff --git a/supabase/migrations/20260914010000_fail_soft_secret_listing_decryption.sql b/supabase/migrations/20260914010000_fail_soft_secret_listing_decryption.sql new file mode 100644 index 0000000000..ee41479090 --- /dev/null +++ b/supabase/migrations/20260914010000_fail_soft_secret_listing_decryption.sql @@ -0,0 +1,272 @@ +-- Fail-soft listing for #620. Corrupt fields retain their authorized row and +-- installed clients receive a non-null blank password. Systemic failures propagate. +-- Review informed by #621 (@Rushikeshiitb) and #652 (@akashjadon04). +-- No crypto format changes: #647 v2 uses random IVs, not authenticated encryption. +-- Rotation continues using strict decrypt_text; this helper is not a format adapter. + +-- ---------------------------------------------------------------------------- +-- try_decrypt_text: NULL on corrupt data; systemic errors still raise. +-- Locked down the same way safe_decrypt_twofa_secret is (20260909000000): no +-- client role gets EXECUTE, so this cannot become a decryption oracle +-- separate from the gated listing RPCs that call it internally as postgres. +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "public"."try_decrypt_text"("ciphertext" "text") RETURNS "text" + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + key_bytes bytea; +BEGIN + IF ciphertext IS NULL THEN + RETURN NULL; + END IF; + + -- Keep missing, malformed and empty key configuration outside the row handler. + key_bytes := public.get_encryption_key()::bytea; + IF key_bytes IS NULL OR octet_length(key_bytes) = 0 THEN + RAISE EXCEPTION USING ERRCODE = '22023', MESSAGE = 'Secret encryption key configuration is invalid'; + END IF; + BEGIN + RETURN public.decrypt_text(ciphertext); + EXCEPTION + WHEN data_exception OR external_routine_invocation_exception THEN + -- Class 22 covers encoding failures; class 39 covers pgcrypto failures. + -- Never include ciphertext, plaintext, keys or exception messages. + RAISE WARNING 'Secret decryption failed (SQLSTATE %)', SQLSTATE; + RETURN NULL; + END; +END; +$$; + +ALTER FUNCTION "public"."try_decrypt_text"("ciphertext" "text") OWNER TO "postgres"; + +COMMENT ON FUNCTION "public"."try_decrypt_text"("ciphertext" "text") IS + 'Decrypt a stored ciphertext value, returning NULL on corrupt data ' + 'instead of aborting the caller (BossConsole#620). Not named safe_decrypt_* ' + '- see the function header in 20260914010000 for why.'; + +REVOKE EXECUTE ON FUNCTION "public"."try_decrypt_text"("ciphertext" "text") + FROM PUBLIC, "anon", "authenticated", "service_role"; +GRANT EXECUTE ON FUNCTION "public"."try_decrypt_text"("ciphertext" "text") TO "postgres"; + + +-- ---------------------------------------------------------------------------- +-- (a) get_user_secrets - verbatim from 20260907000000, decrypt calls only. +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "public"."get_user_secrets"( + "p_limit" integer DEFAULT 50, + "p_offset" integer DEFAULT 0 +) RETURNS TABLE( + "id" "uuid", "website" "text", "username" "text", "password" "text", "notes" "text", + "expiration_date" timestamp with time zone, "tags" "jsonb", "metadata" "jsonb", + "created_at" timestamp with time zone, "updated_at" timestamp with time zone, + "org_id" "uuid", "org_slug" "text", "is_org_owned" boolean, "can_manage" boolean +) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + s.id, s.website, s.username, + COALESCE(public.try_decrypt_text(s.password_encrypted), '') AS password, + s.notes, s.expiration_date, + COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, + COALESCE(( + SELECT jsonb_build_object( + 'twofa_enabled', sm.twofa_enabled, + 'twofa_type', sm.twofa_type, + 'recovery_codes', public.safe_decrypt_recovery_codes(sm.recovery_codes_encrypted) + ) + FROM public.secret_metadata sm WHERE sm.secret_id = s.id + ), '{}'::jsonb) AS metadata, + s.created_at, s.updated_at, + s.org_id, + o.slug AS org_slug, + (s.org_id IS NOT NULL) AS is_org_owned, + (s.user_id = auth.uid() + OR public.is_user_admin(auth.uid()) + OR (s.org_id IS NOT NULL AND public.is_org_admin(s.org_id))) AS can_manage + FROM public.secrets s + LEFT JOIN public.organisations o ON o.id = s.org_id + WHERE s.user_id = auth.uid() + OR (s.org_id IS NOT NULL AND public.is_org_member(s.org_id)) + ORDER BY s.created_at DESC, s.id DESC + LIMIT p_limit OFFSET p_offset; +END; +$$; + +ALTER FUNCTION "public"."get_user_secrets"(integer,integer) OWNER TO "postgres"; + + +-- ---------------------------------------------------------------------------- +-- (b) search_user_secrets - verbatim from 20260907000000, decrypt calls only. +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "public"."search_user_secrets"( + "p_query" "text", + "p_limit" integer DEFAULT 50, + "p_offset" integer DEFAULT 0 +) RETURNS TABLE( + "id" "uuid", "website" "text", "username" "text", "password" "text", "notes" "text", + "expiration_date" timestamp with time zone, "tags" "jsonb", "metadata" "jsonb", + "created_at" timestamp with time zone, "updated_at" timestamp with time zone, + "org_id" "uuid", "org_slug" "text", "is_org_owned" boolean, "can_manage" boolean +) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + s.id, s.website, s.username, + COALESCE(public.try_decrypt_text(s.password_encrypted), '') AS password, + s.notes, s.expiration_date, + COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, + COALESCE(( + SELECT jsonb_build_object( + 'twofa_enabled', sm.twofa_enabled, + 'twofa_type', sm.twofa_type, + 'recovery_codes', public.safe_decrypt_recovery_codes(sm.recovery_codes_encrypted) + ) + FROM public.secret_metadata sm WHERE sm.secret_id = s.id + ), '{}'::jsonb) AS metadata, + s.created_at, s.updated_at, + s.org_id, + o.slug AS org_slug, + (s.org_id IS NOT NULL) AS is_org_owned, + (s.user_id = auth.uid() + OR public.is_user_admin(auth.uid()) + OR (s.org_id IS NOT NULL AND public.is_org_admin(s.org_id))) AS can_manage + FROM public.secrets s + LEFT JOIN public.organisations o ON o.id = s.org_id + WHERE (s.user_id = auth.uid() + OR (s.org_id IS NOT NULL AND public.is_org_member(s.org_id))) + AND (s.website ILIKE '%' || p_query || '%' OR s.username ILIKE '%' || p_query || '%') + ORDER BY s.created_at DESC, s.id DESC + LIMIT p_limit OFFSET p_offset; +END; +$$; + +ALTER FUNCTION "public"."search_user_secrets"("text",integer,integer) OWNER TO "postgres"; + + +-- ---------------------------------------------------------------------------- +-- (c) get_user_secrets_with_shared - verbatim from 20260909000000 (the +-- current definition; it superseded 20260907000000's own copy), +-- decrypt calls only. +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "public"."get_user_secrets_with_shared"( + "p_limit" integer DEFAULT 50, + "p_offset" integer DEFAULT 0 +) RETURNS TABLE( + "id" "uuid", "website" "text", "username" "text", "password" "text", "notes" "text", + "expiration_date" timestamp with time zone, "tags" "jsonb", "metadata" "jsonb", + "created_at" timestamp with time zone, "updated_at" timestamp with time zone, + "is_owner" boolean, "shared_by_email" "text", "access_level" "text", + "org_id" "uuid", "org_slug" "text", "is_org_owned" boolean, + "shared_with_org_slug" "text", "can_manage" boolean +) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + WITH accessible_secrets AS ( + -- Source 1: the caller's own secrets. + SELECT s.id, TRUE AS is_owner, NULL::TEXT AS shared_by_email, + 'owner'::TEXT AS access_level, NULL::TEXT AS shared_with_org_slug, 1 AS priority + FROM public.secrets s + WHERE s.user_id = auth.uid() + + UNION ALL + + -- Source 4: secrets OWNED BY an organisation the caller belongs to. + SELECT s.id, (s.user_id = auth.uid()) AS is_owner, NULL::TEXT, + 'org'::TEXT, o.slug, 2 + FROM public.secrets s + JOIN public.organisations o ON o.id = s.org_id + WHERE s.org_id IS NOT NULL + AND public.is_org_member(s.org_id) + + UNION ALL + + -- Source 2: shared directly with the caller. + SELECT s.id, FALSE, u.email, ss.access_level, NULL::TEXT, 3 + FROM public.secrets s + JOIN public.secret_shares ss ON ss.secret_id = s.id + JOIN auth.users u ON u.id = ss.shared_by + WHERE ss.shared_with_user_id = auth.uid() + AND (ss.expires_at IS NULL OR ss.expires_at > now()) + + UNION ALL + + -- Source 3: shared with a role the caller holds, OR any DESCENDANT of one + -- (20260802010000). Previously matched assigned roles only, which + -- disagreed with how authorize() expands permissions. + SELECT s.id, FALSE, u.email, ss.access_level, NULL::TEXT, 4 + FROM public.secrets s + JOIN public.secret_shares ss ON ss.secret_id = s.id + JOIN auth.users u ON u.id = ss.shared_by + WHERE ss.shared_with_role_id IN (SELECT public.effective_share_role_ids(auth.uid())) + AND (ss.expires_at IS NULL OR ss.expires_at > now()) + + UNION ALL + + -- Source 5: shared with an organisation the caller belongs to. + SELECT s.id, FALSE, u.email, ss.access_level, o.slug, 5 + FROM public.secrets s + JOIN public.secret_shares ss ON ss.secret_id = s.id + JOIN auth.users u ON u.id = ss.shared_by + JOIN public.organisations o ON o.id = ss.shared_with_org_id + WHERE ss.shared_with_org_id IS NOT NULL + AND public.is_org_member(ss.shared_with_org_id) + AND (ss.expires_at IS NULL OR ss.expires_at > now()) + ), + unique_secrets AS ( + SELECT DISTINCT ON (a.id) + a.id, a.is_owner, a.shared_by_email, a.access_level, a.shared_with_org_slug + FROM accessible_secrets a + ORDER BY a.id, a.is_owner DESC, a.priority + ) + SELECT + s.id, s.website, s.username, + COALESCE(public.try_decrypt_text(s.password_encrypted), '') AS password, + s.notes, s.expiration_date, + COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, + COALESCE(( + SELECT jsonb_build_object( + 'twofa_enabled', sm.twofa_enabled, + 'twofa_type', sm.twofa_type, + 'twofa_secret', public.safe_decrypt_twofa_secret(sm.twofa_secret), + 'recovery_codes', public.safe_decrypt_recovery_codes(sm.recovery_codes_encrypted) + ) + FROM public.secret_metadata sm WHERE sm.secret_id = s.id + ), '{}'::jsonb) AS metadata, + s.created_at, s.updated_at, + us.is_owner, us.shared_by_email, us.access_level, + s.org_id, + o.slug AS org_slug, + (s.org_id IS NOT NULL) AS is_org_owned, + us.shared_with_org_slug, + (s.user_id = auth.uid() + OR public.is_user_admin(auth.uid()) + OR (s.org_id IS NOT NULL AND public.is_org_admin(s.org_id))) AS can_manage + FROM unique_secrets us + JOIN public.secrets s ON s.id = us.id + LEFT JOIN public.organisations o ON o.id = s.org_id + ORDER BY s.created_at DESC, s.id DESC + LIMIT p_limit OFFSET p_offset; +END; +$$; + +ALTER FUNCTION "public"."get_user_secrets_with_shared"(integer,integer) OWNER TO "postgres"; + +-- Restate the existing RPC grants, matching the convention every migration +-- that CREATE OR REPLACEs these functions follows (20260907000000, +-- 20260909000000). The explicit-anon-grants event trigger +-- (20260908000000) already preserves pre-existing authenticated/service_role +-- privilege across a replace, but restating is the established belt-and- +-- braces here rather than relying on that alone. +GRANT EXECUTE ON FUNCTION "public"."get_user_secrets"(integer,integer) TO "authenticated", "service_role"; +GRANT EXECUTE ON FUNCTION "public"."search_user_secrets"("text",integer,integer) TO "authenticated", "service_role"; +GRANT EXECUTE ON FUNCTION "public"."get_user_secrets_with_shared"(integer,integer) TO "authenticated", "service_role"; diff --git a/supabase/tests/explicit_anon_and_org_visibility_test.sql b/supabase/tests/explicit_anon_and_org_visibility_test.sql index 5e760d106d..588b4ad885 100644 --- a/supabase/tests/explicit_anon_and_org_visibility_test.sql +++ b/supabase/tests/explicit_anon_and_org_visibility_test.sql @@ -5,7 +5,7 @@ select no_plan(); select ok(to_regprocedure(f) is not null and not has_function_privilege(r, to_regprocedure(f), 'EXECUTE'), r || ' cannot execute ' || f) from unnest(array['anon', 'authenticated']) r cross join unnest(array['public.get_encryption_key()', 'public.encrypt_text(text)', - 'public.decrypt_text(text)', 'public.safe_decrypt_recovery_codes(text)', + 'public.decrypt_text(text)', 'public.try_decrypt_text(text)', 'public.safe_decrypt_recovery_codes(text)', 'public.user_display_name(uuid)', 'public.upsert_plugin_rating(uuid,uuid,integer,text)', 'public.record_plugin_download(uuid,uuid,uuid,text)', 'public.custom_access_token_hook(jsonb)']) f; diff --git a/supabase/tests/fail_soft_secret_listing_decryption_test.sql b/supabase/tests/fail_soft_secret_listing_decryption_test.sql new file mode 100644 index 0000000000..a72952bbe9 --- /dev/null +++ b/supabase/tests/fail_soft_secret_listing_decryption_test.sql @@ -0,0 +1,99 @@ +-- BossConsole#620: a single undecryptable secret must not abort the whole +-- listing. All migrations are already applied by the time this file runs. +BEGIN; +SELECT no_plan(); + +DO $fixture$ +DECLARE existing uuid; +BEGIN + SELECT id INTO existing FROM vault.secrets WHERE name = 'master_encryption_key'; + IF existing IS NULL THEN + PERFORM vault.create_secret('failsoft-test-key-0123456789abcdef012', 'master_encryption_key', 'pgTAP only'); + ELSE + PERFORM vault.update_secret(existing, 'failsoft-test-key-0123456789abcdef012', 'master_encryption_key', 'pgTAP only'); + END IF; +END; +$fixture$; + +-- ---- try_decrypt_text: NULL on damaged data, real plaintext otherwise. +SELECT is(public.try_decrypt_text(NULL), NULL::text, 'NULL input stays NULL'); +SELECT is(public.try_decrypt_text('not even base64!!'), NULL::text, 'garbage input fails closed instead of raising'); +SELECT is(public.try_decrypt_text('v2:dGVzdA=='), NULL::text, 'a well-formed but undecryptable v2 envelope fails closed instead of raising'); +SELECT is( + public.try_decrypt_text(public.encrypt_text('round-trip-me')), + 'round-trip-me', + 'a genuinely encrypted value still round trips' +); + +-- ---- try_decrypt_text is locked down the same way safe_decrypt_twofa_secret is. +-- has_function_privilege(role, ...) checks the named role's grants directly, so +-- these do not depend on which role this connection currently runs as. +SELECT ok(NOT has_function_privilege('anon', 'public.try_decrypt_text(text)', 'EXECUTE'), 'anon cannot call the helper directly'); +SELECT ok(NOT has_function_privilege('authenticated', 'public.try_decrypt_text(text)', 'EXECUTE'), 'authenticated cannot call the helper directly'); +SELECT ok(NOT has_function_privilege('service_role', 'public.try_decrypt_text(text)', 'EXECUTE'), 'service_role cannot call the helper directly either'); + +-- ---- The listing RPCs: one corrupt row must not take down the whole page. +INSERT INTO auth.users (id, email) VALUES + ('d1900000-0000-4000-8000-000000000001', 'failsoft-owner@pgtap.test'); + +-- The two corrupt values below are the same ones already proven to fail +-- try_decrypt_text above: not-base64 (fails at decode) and a well-formed but +-- too-short v2 envelope (fails at the IV/AES stage). Reusing them here keeps +-- this fixture from resting on a third, unverified failure mode. +INSERT INTO public.secrets (id, user_id, website, username, password_encrypted) VALUES + ('d1900000-0000-4000-8000-000000000011', 'd1900000-0000-4000-8000-000000000001', 'failsoft.test', 'good', public.encrypt_text('good-password')), + ('d1900000-0000-4000-8000-000000000012', 'd1900000-0000-4000-8000-000000000001', 'failsoft.test', 'bad', 'not even base64!!'); + +INSERT INTO public.secret_metadata (secret_id, recovery_codes_encrypted) VALUES + ('d1900000-0000-4000-8000-000000000011', public.encrypt_text('["code-a","code-b"]')), + ('d1900000-0000-4000-8000-000000000012', 'v2:dGVzdA=='); + +SELECT set_config('request.jwt.claims', '{"sub":"d1900000-0000-4000-8000-000000000001","role":"authenticated"}', true); +SET LOCAL ROLE authenticated; + +SELECT throws_ok($$ SELECT public.try_decrypt_text('anything') $$, '42501', NULL, 'direct execution as authenticated is denied'); + +-- get_user_secrets +SELECT lives_ok($$ SELECT * FROM public.get_user_secrets() $$, 'get_user_secrets does not raise with a corrupt row in the page'); +SELECT is((SELECT count(*) FROM public.get_user_secrets()), 2::bigint, 'get_user_secrets still returns both rows'); +SELECT is((SELECT password FROM public.get_user_secrets() WHERE id = 'd1900000-0000-4000-8000-000000000011'), 'good-password', 'the good row still decrypts'); +SELECT is((SELECT password FROM public.get_user_secrets() WHERE id = 'd1900000-0000-4000-8000-000000000012'), ''::text, 'the corrupt row is blanked, not raised'); +SELECT is((SELECT metadata->'recovery_codes' FROM public.get_user_secrets() WHERE id = 'd1900000-0000-4000-8000-000000000012'), '[]'::jsonb, 'corrupt recovery codes blank to an empty array'); +SELECT is((SELECT metadata->'recovery_codes' FROM public.get_user_secrets() WHERE id = 'd1900000-0000-4000-8000-000000000011'), '["code-a","code-b"]'::jsonb, 'the good row''s recovery codes still decrypt'); + +-- search_user_secrets +SELECT lives_ok($$ SELECT * FROM public.search_user_secrets('failsoft') $$, 'search_user_secrets does not raise with a corrupt row in the results'); +SELECT is((SELECT count(*) FROM public.search_user_secrets('failsoft')), 2::bigint, 'search_user_secrets still returns both rows'); +SELECT is((SELECT password FROM public.search_user_secrets('failsoft') WHERE id = 'd1900000-0000-4000-8000-000000000012'), ''::text, 'the corrupt row is blanked in search results too'); + +-- get_user_secrets_with_shared +SELECT lives_ok($$ SELECT * FROM public.get_user_secrets_with_shared() $$, 'get_user_secrets_with_shared does not raise with a corrupt row in the page'); +SELECT is((SELECT count(*) FROM public.get_user_secrets_with_shared()), 2::bigint, 'get_user_secrets_with_shared still returns both rows'); +SELECT is((SELECT password FROM public.get_user_secrets_with_shared() WHERE id = 'd1900000-0000-4000-8000-000000000011'), 'good-password', 'the good row still decrypts via the shared RPC'); +SELECT is((SELECT password FROM public.get_user_secrets_with_shared() WHERE id = 'd1900000-0000-4000-8000-000000000012'), ''::text, 'the corrupt row is blanked via the shared RPC, not raised'); + +RESET ROLE; +-- Missing keys and systemic errors must not turn the entire page into blanks. +CREATE OR REPLACE FUNCTION public.get_encryption_key() RETURNS text +LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN + RAISE EXCEPTION USING ERRCODE = 'P0001', MESSAGE = 'synthetic unavailable key'; +END $$; +SELECT throws_ok($$SELECT * FROM public.get_user_secrets()$$, 'P0001', NULL::text, + 'missing key propagates through the listing'); +CREATE OR REPLACE FUNCTION public.get_encryption_key() RETURNS text +LANGUAGE sql SECURITY DEFINER AS $$ SELECT ''::text $$; +SELECT throws_ok($$SELECT public.try_decrypt_text('invalid')$$, '22023', NULL::text, + 'empty key propagates'); +CREATE OR REPLACE FUNCTION public.get_encryption_key() RETURNS text +LANGUAGE sql SECURITY DEFINER AS $$ SELECT 'synthetic-test-key'::text $$; +CREATE OR REPLACE FUNCTION public.decrypt_text(ciphertext text) RETURNS text +LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN + RAISE EXCEPTION USING ERRCODE = ciphertext, MESSAGE = 'synthetic failure'; +END $$; +SELECT throws_ok($$SELECT public.try_decrypt_text('42501')$$, '42501', NULL::text, 'privilege errors propagate'); +SELECT throws_ok($$SELECT public.try_decrypt_text('42883')$$, '42883', NULL::text, 'missing decoder propagates'); +SELECT throws_ok($$SELECT public.try_decrypt_text('XX000')$$, 'XX000', NULL::text, 'internal errors propagate'); +SELECT is(public.try_decrypt_text('39000'), NULL::text, 'pgcrypto failures blank only the field'); +SELECT is(public.try_decrypt_text('22P05'), NULL::text, 'untranslatable UTF-8 blanks only the field'); +SELECT * FROM finish(); +ROLLBACK; From e7446cde61ce8429691f4ebd3fd0e318fecadcd8 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Tue, 15 Sep 2026 06:05:01 +0530 Subject: [PATCH 15/49] fix(kernel): require verified caller identity for the project-data bridge (#666) * fix(kernel): require verified caller identity for the project-data bridge BossConsole#53: ProjectDataServiceBridge 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 watch every recently opened project's path (which routinely contains a username) with no credential, and could force an arbitrary window to switch its open project via selectProject. That is not only a UI surprise: a project switch publishes a ProjectChangeEvent on the application-wide event bus every installed plugin can subscribe to, so an unauthenticated caller could trigger that broadcast on demand. Applies the same fail-closed pattern already used for ActiveTabs, Download, Git, Log, PluginUI, RoleManagement, RunConfig and Supabase: every unary RPC requires AUTHENTICATED_PROCESS_ID, the watch stream captures CURRENT_IDENTITY before collecting and re-checks it on every emission so a revoked token stops a live stream, and every mutation gets an attributed audit log line. SplitView remains the one unguarded service on this server. Rewrote ProjectDataServiceBridgeTest against a real gRPC server (the existing tests called the bridge directly, which the new gating would have refused outright) - refusal-with-no-credential, authenticated mutation, revoked unary and revoked stream cases, plus the two existing routing guarantees now exercised over an authenticated channel: selectProject's UI-dispatcher confinement and watchRecentProjects reading the process-wide ProjectState singleton rather than a per-window mirror. Also folded the stale, repeatedly-drifting enumeration of which bridges fail closed out of BossIpcServer's own KDoc - it named only two of what are now nine - in favor of pointing at each bridge's own KDoc instead. * fix(review): clarify project identity guarantees and harden test lifecycle * style(test): keep filtered flow chain continuation together --------- Co-authored-by: Shivang --- .../services/ActiveTabsServiceBridge.kt | 4 +- .../services/ProjectDataServiceBridge.kt | 81 ++++- .../services/ProjectDataServiceBridgeTest.kt | 300 +++++++++++++++--- .../kotlin/ai/rever/boss/ipc/BossIpcServer.kt | 12 +- 4 files changed, 349 insertions(+), 48 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 0398ab8c80..5284c5da6a 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. BossConsole#53 tracks the remaining unguarded + * services on this server. */ // 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/ProjectDataServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridge.kt index f065906f16..3888bbe6da 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridge.kt @@ -1,16 +1,38 @@ package ai.rever.boss.kernel.services import ai.rever.boss.components.plugin.panels.left_top.ProjectState +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.ProjectData import ai.rever.boss.plugin.api.ProjectDataProvider +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext +/** + * Kernel-side bridge for `ProjectDataService`. + * + * **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, RoleManagement 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 [watchRecentProjects] to see every recently opened + * project's path (which routinely contains a username, per this repo's own AGENTS.md), and could + * [selectProject] to force an arbitrary window to switch its open project. That is not merely a + * UI surprise: a project switch publishes a [ai.rever.boss.plugin.api.ProjectChangeEvent] on the + * application-wide event bus every installed plugin can subscribe to (see AGENTS.md's "Browser + * telemetry" section), so an unauthenticated caller could trigger that broadcast on demand and + * hand every plugin a filesystem path it never asked to see. + * + * BossConsole#53 tracks the remaining unguarded services on this server. + */ class ProjectDataServiceBridge( private val provider: ProjectDataProvider, private val uiDispatcher: CoroutineDispatcher = Dispatchers.Main, @@ -21,10 +43,20 @@ class ProjectDataServiceBridge( * (`ProjectDataProviderImpl`) that stops updating once its owning window disposes it * (BossConsole#520); a KERNEL client watching it would freeze at whatever it last saw. This * stream has no such owner to outlive, so it keeps working across every window's lifecycle. + * Revocation is checked before each emission; idle streams are not proactively disconnected, + * and snapshots already delivered to a caller cannot be recalled. */ - override fun watchRecentProjects(request: Empty): Flow = - flow { + override fun watchRecentProjects(request: Empty): Flow { + // Capture the gRPC context before returning the asynchronously collected flow - the same + // shape ActiveTabsServiceBridge.watchActiveTabs uses, and for the same reason: the + // interceptor's Context is not carried into the coroutine that later collects this flow. + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = currentIdentity?.invoke() ?: refuseIdentity("watchRecentProjects") ProjectState.recentProjects.collect { projects -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchRecentProjects", "revocation") + } emit( ProjectListResponse .newBuilder() @@ -41,8 +73,11 @@ class ProjectDataServiceBridge( ) } } + } override suspend fun updateRecentProjects(request: ProjectProto): Empty { + val caller = authenticatedCallerOrRefuse("updateRecentProjects") + logMutation("updateRecentProjects", caller) provider.updateRecentProjects( ProjectData( name = request.name, @@ -54,11 +89,15 @@ class ProjectDataServiceBridge( } override suspend fun removeRecentProject(request: ProjectPathRequest): Empty { + val caller = authenticatedCallerOrRefuse("removeRecentProject") + logMutation("removeRecentProject", caller) provider.removeRecentProject(request.path) return Empty.getDefaultInstance() } override suspend fun selectProject(request: ProjectProto): Empty { + val caller = authenticatedCallerOrRefuse("selectProject") + logMutation("selectProject", caller) // The provider mutates per-window UI state and synchronously announces that change. // Confining the only non-UI entry point here keeps the state and its previous-path event // chain ordered without making third-party event handlers run under a host lock. @@ -73,4 +112,42 @@ class ProjectDataServiceBridge( } return Empty.getDefaultInstance() } + + /** + * Unary RPCs only: the verified identity, or a thrown `PERMISSION_DENIED` when there is none. + * + * Mirrors the helper introduced by PR #505 (BossConsole#53) - fails closed rather than let a + * request with no credential fall through to [provider] with nothing to attribute it to. + */ + private fun authenticatedCallerOrRefuse(rpc: String): String = + ProcessIdentityInterceptor.AUTHENTICATED_PROCESS_ID.get() ?: refuseIdentity(rpc) + + private fun logMutation( + rpc: String, + caller: String, + ) { + logger.info( + LogCategory.AUTH, + "Authenticated project mutation requested", + mapOf("rpc" to rpc, "caller" to caller), + ) + } + + private fun refuseIdentity( + rpc: String, + stage: String = "bind", + ): Nothing { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no current verified process identity on this call", + mapOf("rpc" to rpc, "stage" to stage), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + + private companion object { + val logger = BossLogger.forComponent("ProjectDataServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridgeTest.kt index e1991b265a..c81e870437 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridgeTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ProjectDataServiceBridgeTest.kt @@ -1,28 +1,59 @@ package ai.rever.boss.kernel.services import ai.rever.boss.components.plugin.panels.left_top.ProjectState +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.ProjectDataServiceGrpcKt +import ai.rever.boss.ipc.proto.services.ProjectPathRequest import ai.rever.boss.ipc.proto.services.ProjectProto import ai.rever.boss.plugin.api.ProjectData import ai.rever.boss.plugin.api.ProjectDataProvider import ai.rever.boss.window.Project +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.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withTimeout import java.nio.file.Files +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals import kotlin.test.assertTrue /** - * Two routing guarantees on the kernel bridge: + * The project-data bridge, exercised over a real gRPC server (BossConsole#53). + * + * Before this bridge checked identity, any process able to open a connection to the kernel IPC + * server - not only the plugins the host itself loaded - could watch every recently opened + * project's path with no credential at all, and could force an arbitrary window to switch its + * open project via `selectProject`, which also broadcasts a project-change event to every + * installed plugin. + * + * Two routing guarantees, unchanged by the identity gating and still exercised (now over an + * authenticated channel rather than a direct call): * * 1. `selectProject` confines provider mutation to the UI dispatcher, which lets * project-change announcement stay lock-free. @@ -34,19 +65,144 @@ import kotlin.test.assertTrue * whatever the window last saw. */ class ProjectDataServiceBridgeTest { + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var server: Server + private lateinit var authenticatedChannel: ManagedChannel + private lateinit var anonymousChannel: ManagedChannel + private lateinit var authenticated: ProjectDataServiceGrpcKt.ProjectDataServiceCoroutineStub + private lateinit var anonymous: ProjectDataServiceGrpcKt.ProjectDataServiceCoroutineStub + + private fun startServer( + provider: ProjectDataProvider, + uiDispatcher: CoroutineDispatcher = Dispatchers.Main, + ) { + tokenRegistry = ProcessTokenRegistry() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(ProjectDataServiceBridge(provider, uiDispatcher)) + .build() + .start() + authenticatedChannel = + ManagedChannelBuilder + .forAddress("localhost", server.port) + .usePlaintext() + .intercept(ProcessTokenClientInterceptor(tokenRegistry.issue(CALLER))) + .build() + anonymousChannel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + authenticated = ProjectDataServiceGrpcKt.ProjectDataServiceCoroutineStub(authenticatedChannel) + anonymous = ProjectDataServiceGrpcKt.ProjectDataServiceCoroutineStub(anonymousChannel) + } + + @AfterTest + fun tearDown() { + if (::authenticatedChannel.isInitialized) { + authenticatedChannel.shutdownNow() + authenticatedChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + if (::anonymousChannel.isInitialized) { + anonymousChannel.shutdownNow() + anonymousChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + if (::server.isInitialized) { + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + } + + @Test + fun `every RPC is refused with no credential, and never reaches the provider`() = + runBlocking { + val provider = RecordingProjectDataProvider() + startServer(provider) + + assertRefused { anonymous.watchRecentProjects(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { + anonymous.updateRecentProjects( + ProjectProto + .newBuilder() + .setName("n") + .setPath("/tmp/x") + .setLastOpened(1L) + .build(), + ) + } + assertRefused { + anonymous.removeRecentProject(ProjectPathRequest.newBuilder().setPath("/tmp/x").build()) + } + assertRefused { + anonymous.selectProject( + ProjectProto + .newBuilder() + .setName("n") + .setPath("/tmp/x") + .setLastOpened(1L) + .build(), + ) + } + + assertTrue(provider.calls.isEmpty(), "a refused call must never reach the provider") + } + @Test - fun `selectProject switches from the grpc caller to the UI dispatcher`() { - val provider = RecordingProjectDataProvider() - val executor = - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, UI_THREAD_NAME) + fun `an authenticated caller can update, remove and select`() = + runBlocking { + val provider = RecordingProjectDataProvider() + startServer(provider) + + authenticated.updateRecentProjects( + ProjectProto + .newBuilder() + .setName("n") + .setPath("/tmp/x") + .setLastOpened(1L) + .build(), + ) + authenticated.removeRecentProject(ProjectPathRequest.newBuilder().setPath("/tmp/x").build()) + authenticated.selectProject( + ProjectProto + .newBuilder() + .setName("Selected") + .setPath("/tmp/boss-bridge-selected") + .setLastOpened(42L) + .build(), + ) + + assertEquals(listOf("updateRecentProjects", "removeRecentProject", "selectProject"), provider.calls) + assertEquals( + ProjectData(name = "Selected", path = "/tmp/boss-bridge-selected", lastOpened = 42L), + provider.selectedProject, + ) + } + + @Test + fun `revoked caller cannot invoke a unary RPC`() = + runBlocking { + val provider = RecordingProjectDataProvider() + startServer(provider) + + tokenRegistry.revoke(CALLER) + assertRefused { + authenticated.removeRecentProject(ProjectPathRequest.newBuilder().setPath("/tmp/x").build()) } + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `selectProject switches from the grpc caller to the UI dispatcher`() = + runBlocking { + val provider = RecordingProjectDataProvider() + val executor = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, UI_THREAD_NAME) + } + + executor.asCoroutineDispatcher().use { uiDispatcher -> + startServer(provider, uiDispatcher) - executor.asCoroutineDispatcher().use { uiDispatcher -> - val bridge = ProjectDataServiceBridge(provider, uiDispatcher) - val response = - runBlocking { - bridge.selectProject( + val response = + authenticated.selectProject( ProjectProto .newBuilder() .setName("Selected") @@ -54,20 +210,19 @@ class ProjectDataServiceBridgeTest { .setLastOpened(42L) .build(), ) - } - assertEquals(Empty.getDefaultInstance(), response) - } + assertEquals(Empty.getDefaultInstance(), response) + } - assertTrue( - provider.selectionThread?.startsWith(UI_THREAD_NAME) == true, - "selection ran on ${provider.selectionThread}", - ) - assertEquals( - ProjectData(name = "Selected", path = "/tmp/boss-bridge-selected", lastOpened = 42L), - provider.selectedProject, - ) - } + assertTrue( + provider.selectionThread?.startsWith(UI_THREAD_NAME) == true, + "selection ran on ${provider.selectionThread}", + ) + assertEquals( + ProjectData(name = "Selected", path = "/tmp/boss-bridge-selected", lastOpened = 42L), + provider.selectedProject, + ) + } @Test fun `watchRecentProjects reflects the process-wide ProjectState, not the per-window provider`() = @@ -88,33 +243,38 @@ class ProjectDataServiceBridgeTest { val second = Project(name = "boss-bridge-second", path = secondDir.absolutePath, lastOpened = 0L) try { val decoy = ProjectData(name = "decoy-window-only-project", path = "/nowhere/decoy", lastOpened = 0L) - val bridge = ProjectDataServiceBridge(FakeProjectDataProvider(MutableStateFlow(listOf(decoy)))) + startServer(FakeProjectDataProvider(MutableStateFlow(listOf(decoy)))) awaitInRecentProjects(seed) - val expected = ProjectState.recentProjects.value.map { it.path } - assertTrue(seed.path in expected, "seed disappeared before the first emission") - - // One collection, two emissions: the first pins the routing (the value - // equals the process-wide list, not the per-window decoy), the second - // pins liveness (a later mutation still reaches a client that keeps - // collecting). + // Observe a seeded snapshot and then a later mutation on one stream. Ignore + // the singleton's possible late startup-load emission and reseed while waiting; + // neither an empty stream nor the per-window decoy can satisfy these assertions. val emissions = mutableListOf>() val collector = launch { - bridge + authenticated .watchRecentProjects(Empty.getDefaultInstance()) - .take(2) + .filter { response -> + val awaitedPath = if (emissions.isEmpty()) seed.path else second.path + response.projectsList.any { it.path == awaitedPath } + }.take(2) .collect { emissions += it.projectsList.map { p -> p.path } } } withTimeout(5_000) { - while (emissions.size < 1) delay(10) + while (emissions.size < 1) { + ProjectState.updateRecentProjects(seed) + delay(10) + } } - assertEquals(expected, emissions.getOrNull(0), "first emission must be the process-wide list") + assertTrue(seed.path in emissions[0], "first observed snapshot must contain the global seed") assertNotEquals(listOf(decoy.path), emissions.getOrNull(0), "emission came from the per-window mirror") ProjectState.updateRecentProjects(second) withTimeout(5_000) { - while (emissions.size < 2) delay(10) + while (emissions.size < 2) { + ProjectState.updateRecentProjects(second) + delay(10) + } } collector.join() assertTrue( @@ -129,6 +289,61 @@ class ProjectDataServiceBridgeTest { } } + @Test + fun `revoked watcher cannot receive a later snapshot`() = + runBlocking { + withTimeout(10_000) { + val seedDir = Files.createTempDirectory("boss-bridge-revoke-watch-seed").toFile() + val seed = Project(name = "boss-bridge-revoke-seed", path = seedDir.absolutePath, lastOpened = 0L) + try { + startServer(FakeProjectDataProvider(MutableStateFlow(emptyList()))) + awaitInRecentProjects(seed) + + supervisorScope { + val received = Channel(Channel.UNLIMITED) + val emissions = mutableListOf>() + val watching = + async { + authenticated.watchRecentProjects(Empty.getDefaultInstance()).collect { + emissions += it.projectsList.map { p -> p.path } + received.send(Unit) + } + } + try { + received.receive() + tokenRegistry.revoke(CALLER) + val second = + Project( + name = "boss-bridge-revoke-second", + path = "/nowhere/after-revocation", + lastOpened = 0L, + ) + ProjectState.updateRecentProjects(second) + + val failure = assertFailsWith { watching.await() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue( + emissions.none { second.path in it }, + "revocation must prevent a later snapshot from reaching the caller", + ) + } finally { + watching.cancel() + received.close() + } + } + } finally { + ProjectState.removeRecentProject(seed.path) + ProjectState.removeRecentProject("/nowhere/after-revocation") + seedDir.deleteRecursively() + } + } + } + + private suspend fun assertRefused(call: suspend () -> Unit) { + val failure = assertFailsWith { call() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + /** * Seeds [project] into the real [ProjectState] and returns once it is present. Reseeds on * every poll, because the singleton's one-shot disk load can run once, late, and replace @@ -144,23 +359,32 @@ class ProjectDataServiceBridgeTest { } } + /** Records every method it was actually asked to perform, so a refusal can be proven silent. */ private class RecordingProjectDataProvider : ProjectDataProvider { + val calls = CopyOnWriteArrayList() override val recentProjects: StateFlow> = MutableStateFlow(emptyList()) var selectedProject: ProjectData? = null var selectionThread: String? = null - override fun updateRecentProjects(project: ProjectData) = Unit + override fun updateRecentProjects(project: ProjectData) { + calls += "updateRecentProjects" + } - override fun removeRecentProject(projectPath: String) = Unit + override fun removeRecentProject(projectPath: String) { + calls += "removeRecentProject" + } override fun selectProject(project: ProjectData) { + calls += "selectProject" selectedProject = project selectionThread = Thread.currentThread().name } } private companion object { + const val CALLER = "project-panel-plugin" const val UI_THREAD_NAME = "project-data-ui-test" + const val SHUTDOWN_TIMEOUT_MS = 5_000L } } 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..6d8aa6dcb0 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,12 @@ 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 a service that fails closed on a missing identity - a growing list + * under `ai.rever.boss.kernel.services`, deliberately not enumerated here since the list has + * already drifted stale once: check each bridge's own KDoc for whether it requires identity. + * With no registry, every call to such a 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. */ private val tokenRegistry: ProcessTokenRegistry? = null, ) { From 8534e83983aa2f1d294fb1a30657cefe835ac484 Mon Sep 17 00:00:00 2001 From: Abhiram Nair Date: Tue, 15 Sep 2026 06:12:55 +0530 Subject: [PATCH 16/49] fix(git): make clone timeout cancellable (#660) Fixes #641. Consolidates the independently implemented clone-timeout work from #659 and #660. Abhiram Nair authored the original onExit lifecycle, deterministic process tests, real process-tree coverage and public clone cleanup/retry repairs. Antriksh independently authored the interruptible-wait alternative and real-Git acceptance tests; target preservation, ordinary failure and final-completion coverage are incorporated with explicit credit. Maintainer repairs serialize interruptible progress delivery, make callback failures advisory and preserve successful exit during delayed notification. Co-authored-by: passionate-coder26 Co-authored-by: Antriksh --- .../kotlin/ai/rever/boss/git/GitService.kt | 2 + .../ai/rever/boss/git/DesktopGitService.kt | 259 +++++----- .../ai/rever/boss/git/GitCloneProcess.kt | 272 +++++++++++ .../ai/rever/boss/git/GitCloneProcessTest.kt | 449 ++++++++++++++++++ .../rever/boss/git/GitCloneProcessTreeTest.kt | 313 ++++++++++++ .../git/GitCloneRepositoryLifecycleTest.kt | 268 +++++++++++ 6 files changed, 1449 insertions(+), 114 deletions(-) create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/git/GitCloneProcess.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneProcessTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneProcessTreeTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneRepositoryLifecycleTest.kt diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/git/GitService.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/git/GitService.kt index 505bebec73..ee0c1ef94c 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/git/GitService.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/git/GitService.kt @@ -442,6 +442,8 @@ expect object GitService { * @param onProgress Callback for progress updates (receives progress messages) * @return Result indicating success or failure with appropriate message */ + // Progress callbacks are serialized and settle before return. They must be + // prompt and must not suppress thread interruption during cancellation. suspend fun cloneRepository( repositoryUrl: String, targetDirectory: String, diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/git/DesktopGitService.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/git/DesktopGitService.kt index 233768249e..240d2e29b6 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/git/DesktopGitService.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/git/DesktopGitService.kt @@ -10,11 +10,13 @@ import ai.rever.boss.utils.logging.LogCategory import ai.rever.boss.utils.logging.LogSanitizer import ai.rever.boss.window.WindowGitState import ai.rever.boss.window.WindowGitStateRegistry +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -22,7 +24,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout import java.io.BufferedReader import java.io.File import java.io.IOException @@ -30,9 +31,17 @@ import java.io.InputStreamReader import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext import ai.rever.boss.plugin.git.GitOperationResult.Error as GitError import ai.rever.boss.plugin.git.GitOperationResult.Success as GitSuccess +private class GitCloneTimeoutContext( + val timeoutMillis: Long, +) : AbstractCoroutineContextElement(GitCloneTimeoutContext) { + companion object Key : CoroutineContext.Key +} + /** * Desktop implementation of GitService using git CLI. * @@ -2224,6 +2233,20 @@ actual object GitService { * @param onProgress Callback for progress updates * @return GitOperationResult indicating success or failure */ + internal suspend fun cloneRepositoryWithTimeout( + repositoryUrl: String, + targetDirectory: String, + onProgress: (String) -> Unit, + timeoutMillis: Long, + ): GitOperationResult = + withContext(GitCloneTimeoutContext(timeoutMillis)) { + cloneRepository( + repositoryUrl = repositoryUrl, + targetDirectory = targetDirectory, + onProgress = onProgress, + ) + } + actual suspend fun cloneRepository( repositoryUrl: String, targetDirectory: String, @@ -2239,6 +2262,9 @@ actual object GitService { ), ) + val effectiveTimeout = + currentCoroutineContext()[GitCloneTimeoutContext]?.timeoutMillis + ?: GIT_CLONE_TIMEOUT_MILLIS try { // Check if git is available if (!checkGitAvailable()) { @@ -2277,124 +2303,139 @@ actual object GitService { return@withContext GitError(error) } - // Execute git clone with progress, wrapped in timeout (10 minutes for large repos) - withTimeout(600_000L) { - // 10 minutes timeout - onProgress("Initializing clone...") - - val process = - ProcessBuilder( - "git", - "clone", - "--progress", - repositoryUrl, - targetDirectory, - ).apply { - // Inherit parent process environment for SSH/git credentials - environment().putAll(System.getenv()) - }.redirectErrorStream(true) // Merge stderr into stdout for progress - .start() - - try { - // Read progress output - BufferedReader(InputStreamReader(process.inputStream)).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - line?.let { progressLine -> - // Git progress comes on stderr, but we redirected it to stdout - // Filter and send meaningful progress updates - when { - progressLine.contains("Cloning into") -> { - onProgress("Cloning repository...") - } - - progressLine.contains("remote: Counting objects") -> { - onProgress("Receiving objects...") - } - - progressLine.contains("Receiving objects") -> { - // Extract percentage if available - val percentMatch = Regex("(\\d+)%").find(progressLine) - if (percentMatch != null) { - onProgress("Receiving objects: ${percentMatch.value}") - } else { - onProgress("Receiving objects...") - } - } - - progressLine.contains("Resolving deltas") -> { - val percentMatch = Regex("(\\d+)%").find(progressLine) - if (percentMatch != null) { - onProgress("Resolving deltas: ${percentMatch.value}") - } else { - onProgress("Resolving deltas...") - } - } - - progressLine.contains("Checking out files") -> { - onProgress("Checking out files...") - } - } - logger.debug(LogCategory.GENERAL, "Clone progress: $progressLine") - } - } - } + // Execute git clone with progress and a cancellable 10-minute timeout + runCatching { onProgress("Initializing clone...") } - val exitCode = process.waitFor() + val process = + ProcessBuilder( + "git", + "clone", + "--progress", + repositoryUrl, + targetDirectory, + ).apply { + // Inherit parent process environment for SSH/git credentials + environment().putAll(System.getenv()) + }.redirectErrorStream(true) // Merge stderr into stdout for progress + .start() + + val exitCode = + runCloneProcess( + process = process, + timeoutMillis = + effectiveTimeout, + onOutputLine = { progressLine -> + // Git progress comes on stderr, but we redirected it to stdout + // Filter and send meaningful progress updates + when { + progressLine.contains("Cloning into") -> { + onProgress("Cloning repository...") + } - if (exitCode == 0) { - onProgress("Clone completed successfully") - logger.info( - LogCategory.GENERAL, - "Repository cloned successfully", - mapOf("target" to targetDirectory), - ) - GitSuccess() - } else { - val errorMessage = - when { - repositoryUrl.contains("@") && exitCode == 128 -> { - "Authentication failed. Please configure your SSH keys or git credentials." - } + progressLine.contains("remote: Counting objects") -> { + onProgress("Receiving objects...") + } - exitCode == 128 -> { - "Repository not found or access denied. Please check the URL and your permissions." + progressLine.contains("Receiving objects") -> { + val percentMatch = Regex("(\\d+)%").find(progressLine) + if (percentMatch != null) { + onProgress("Receiving objects: ${percentMatch.value}") + } else { + onProgress("Receiving objects...") } + } - exitCode == 1 -> { - "Network error. Please check your internet connection." + progressLine.contains("Resolving deltas") -> { + val percentMatch = Regex("(\\d+)%").find(progressLine) + if (percentMatch != null) { + onProgress("Resolving deltas: ${percentMatch.value}") + } else { + onProgress("Resolving deltas...") } + } - else -> { - "Clone failed with exit code $exitCode. Please check the repository URL and try again." - } + progressLine.contains("Checking out files") -> { + onProgress("Checking out files...") } - logger.error( - LogCategory.GENERAL, - "Clone failed", - mapOf("exitCode" to exitCode, "message" to errorMessage), - ) - GitError(errorMessage) - } - } finally { - // Ensure process is destroyed if still running - if (process.isAlive) { - process.destroyForcibly() + } + logger.debug(LogCategory.GENERAL, "Clone progress: $progressLine") + }, + onCancellation = { + val deletionResult = + runCatching { + targetDir.deleteRecursively() + } + + deletionResult.exceptionOrNull()?.let { cleanupError -> + logger.warn( + LogCategory.GENERAL, + "Failed to clean up after clone abort", + error = cleanupError, + ) + } + + if (deletionResult.getOrNull() == false && targetDir.exists()) { + logger.warn( + LogCategory.GENERAL, + "Failed to remove partial clone directory: ${targetDir.absolutePath}", + ) + } + }, + outputLifecycle = + CloneOutputLifecycle( + onFailure = { outputError -> + logger.warn( + LogCategory.GENERAL, + "Git clone completed before its progress reader settled", + error = outputError, + ) + }, + ), + ) + + if (exitCode == 0) { + runCatching { onProgress("Clone completed successfully") } + logger.info( + LogCategory.GENERAL, + "Repository cloned successfully", + mapOf("target" to targetDirectory), + ) + GitSuccess() + } else { + val errorMessage = + when { + repositoryUrl.contains("@") && exitCode == 128 -> { + "Authentication failed. Please configure your SSH keys or git credentials." + } + + exitCode == 128 -> { + "Repository not found or access denied. Please check the URL and your permissions." + } + + exitCode == 1 -> { + "Network error. Please check your internet connection." + } + + else -> { + "Clone failed with exit code $exitCode. Please check the repository URL and try again." + } } - } + logger.error( + LogCategory.GENERAL, + "Clone failed", + mapOf("exitCode" to exitCode, "message" to errorMessage), + ) + GitError(errorMessage) } } catch (e: TimeoutCancellationException) { val errorMessage = - "Clone operation timed out after 10 minutes. " + + "Clone operation timed out after ${effectiveTimeout / 1000} seconds. " + "The repository may be too large or the connection too slow. Try cloning from terminal instead." logger.error(LogCategory.GENERAL, errorMessage, error = e) - // Clean up partial clone - try { - File(targetDirectory).deleteRecursively() - } catch (cleanupError: Exception) { - logger.warn(LogCategory.GENERAL, "Failed to clean up after timeout", error = cleanupError) - } + // Clone lifecycle has already stopped the process before removing its partial destination. GitError(errorMessage) + } catch (e: CancellationException) { + throw e } catch (e: IOException) { val errorMessage = when { @@ -2426,16 +2467,6 @@ actual object GitService { ).parentFile?.absolutePath}'." logger.error(LogCategory.GENERAL, errorMessage, error = e) GitError(errorMessage) - } catch (e: InterruptedException) { - val errorMessage = "Clone operation was interrupted. Please try again." - logger.error(LogCategory.GENERAL, errorMessage, error = e) - // Clean up partial clone - try { - File(targetDirectory).deleteRecursively() - } catch (cleanupError: Exception) { - logger.warn(LogCategory.GENERAL, "Failed to clean up after interruption", error = cleanupError) - } - GitError(errorMessage) } catch (e: Exception) { val errorMessage = "Unexpected error during clone: ${e.message ?: e.javaClass.simpleName}. Please check logs for details." logger.error(LogCategory.GENERAL, errorMessage, error = e) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/git/GitCloneProcess.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/git/GitCloneProcess.kt new file mode 100644 index 0000000000..c888b6f644 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/git/GitCloneProcess.kt @@ -0,0 +1,272 @@ +package ai.rever.boss.git + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.concurrent.TimeUnit + +internal const val GIT_CLONE_TIMEOUT_MILLIS = 600_000L +private const val CLONE_PROCESS_KILL_TIMEOUT_SECONDS = 5L +private const val CLONE_OUTPUT_READER_JOIN_TIMEOUT_MILLIS = 5_000L + +private class CloneOutputReader( + val thread: Thread, + val lines: Channel, +) + +internal class CloneOutputLifecycle( + val onClosed: () -> Unit = {}, + val onFailure: (Throwable) -> Unit = {}, +) + +internal fun cloneOutputReaderThreadName(process: Process): String { + val processId = runCatching { process.pid() }.getOrDefault(-1L) + return "boss-git-clone-output-$processId" +} + +/** + * Streams advisory progress while independently awaiting process exit. The pipe + * reader only queues lines; callbacks belong to this coroutine and are settled + * before return. Callbacks must be prompt and must not suppress interruption. + * Interruptible callbacks allow cancellation without holding a cleanup lock. + */ +internal suspend fun runCloneProcess( + process: Process, + timeoutMillis: Long, + onOutputLine: (String) -> Unit, + onCancellation: () -> Unit, + outputLifecycle: CloneOutputLifecycle = CloneOutputLifecycle(), +): Int { + val processResult = CompletableDeferred() + val outputReader = createCloneOutputReader(process) + var processExitObserved = false + var callbacksEnabled = true + val publish: (String) -> Unit = { line -> + if (callbacksEnabled) { + runCatching { onOutputLine(line) }.onFailure { failure -> + if (failure is CancellationException || failure is InterruptedException) throw failure + callbacksEnabled = false + runCatching { outputLifecycle.onFailure(failure) } + } + } + } + try { + registerCloneProcessExit(process, processResult) + outputReader.thread.start() + val exitCode = + withTimeout(timeoutMillis) { + awaitCloneResult(process, processResult, outputReader.lines, publish) + } + processExitObserved = true + // Git's result is authoritative. Draining trailing advisory output has its + // own bound; a slow callback is interrupted and settled before completion. + runCatching { + withTimeout(CLONE_OUTPUT_READER_JOIN_TIMEOUT_MILLIS) { + for (line in outputReader.lines) { + runInterruptible(Dispatchers.IO) { publish(line) } + } + } + }.onFailure { failure -> + currentCoroutineContext().ensureActive() + runCatching { outputLifecycle.onFailure(failure) } + } + return exitCode + } finally { + outputReader.lines.cancel() + runCatching { outputLifecycle.onClosed() } + cleanupCloneProcess( + process = process, + outputReader = outputReader.thread, + cancellationCleanup = if (processExitObserved) null else onCancellation, + ) + } +} + +private suspend fun awaitCloneResult( + process: Process, + processResult: CompletableDeferred, + lines: Channel, + onOutputLine: (String) -> Unit, +): Int { + var outputOpen = true + while (true) { + val exitCode = + select { + // Prefer a known exit over further advisory output, including a + // reader error racing EOF after a successful process exit. + processResult.onAwait { it } + if (outputOpen) { + lines.onReceiveCatching { result -> + val failure = result.exceptionOrNull() + val line = result.getOrNull() + if (failure != null) { + // onExit delivery can lag the actual OS exit. A pipe + // error must not delete a checkout Git already completed. + runCatching { process.exitValue() }.getOrElse { throw failure } + } else if (line == null) { + outputOpen = false + null + } else { + deliverCloneProgress(processResult, line, onOutputLine) + } + } + } + } + if (exitCode != null) return exitCode + } +} + +private suspend fun deliverCloneProgress( + processResult: CompletableDeferred, + line: String, + onOutputLine: (String) -> Unit, +): Int? = + coroutineScope { + val delivery = + async { + runCatching { runInterruptible(Dispatchers.IO) { onOutputLine(line) } } + } + try { + select { + processResult.onAwait { it } + delivery.onAwait { result -> + result.getOrThrow() + null + } + } + } finally { + delivery.cancelAndJoin() + } + } + +private fun createCloneOutputReader(process: Process): CloneOutputReader { + // Progress is advisory: bound memory and retain recent updates if a producer + // outruns its consumer. The pipe thread must never block on user code. + val lines = Channel(64, BufferOverflow.DROP_OLDEST) + val outputReader = + Thread( + { + val failure = + runCatching { + BufferedReader(InputStreamReader(process.inputStream, Charsets.UTF_8)).use { reader -> + var line = reader.readLine() + while (line != null && lines.trySend(line).isSuccess) { + line = reader.readLine() + } + } + }.exceptionOrNull() + lines.close(failure) + }, + cloneOutputReaderThreadName(process), + ).apply { isDaemon = true } + return CloneOutputReader(outputReader, lines) +} + +/** + * Kept clone-local because this source survives Windows ARM64 filtering while + * the process helpers in the kernel package do not. + */ +private fun snapshotCloneProcessDescendants(process: Process): List = + runCatching { + process + .toHandle() + .descendants() + .use { it.toList() } + }.getOrDefault(emptyList()) + +private fun killCloneProcessDescendants(descendants: List) { + descendants.forEach { descendant -> + runCatching { + if (descendant.isAlive) { + descendant.destroyForcibly() + } + } + } +} + +private fun awaitCloneProcessDescendantsExit(descendants: List) { + val deadlineNanos = + System.nanoTime() + + TimeUnit.SECONDS.toNanos(CLONE_PROCESS_KILL_TIMEOUT_SECONDS) + + descendants.forEach { descendant -> + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0L) { + return + } + + runCatching { + descendant.onExit().get(remainingNanos, TimeUnit.NANOSECONDS) + } + } +} + +private suspend fun cleanupCloneProcess( + process: Process, + outputReader: Thread, + cancellationCleanup: (() -> Unit)?, +) { + withContext(NonCancellable + Dispatchers.IO) { + val descendants = snapshotCloneProcessDescendants(process) + + if (process.isAlive) { + killCloneProcessDescendants(descendants) + runCatching { process.destroyForcibly() } + runCatching { + process.waitFor( + CLONE_PROCESS_KILL_TIMEOUT_SECONDS, + TimeUnit.SECONDS, + ) + } + } + + killCloneProcessDescendants(descendants) + awaitCloneProcessDescendantsExit(descendants) + runCatching { process.outputStream.close() } + runCatching { process.inputStream.close() } + runCatching { process.errorStream.close() } + + outputReader.interrupt() + runCatching { + outputReader.join(CLONE_OUTPUT_READER_JOIN_TIMEOUT_MILLIS) + } + + if (cancellationCleanup != null) { + runCatching { cancellationCleanup() } + } + } +} + +private fun registerCloneProcessExit( + process: Process, + processResult: CompletableDeferred, +) { + val exitFuture = + runCatching { process.onExit() } + .getOrElse { error -> + processResult.completeExceptionally(error) + return + } + + exitFuture.whenComplete { completedProcess, error -> + if (error == null) { + processResult.complete(completedProcess.exitValue()) + } else { + processResult.completeExceptionally(error) + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneProcessTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneProcessTest.kt new file mode 100644 index 0000000000..c0eb778799 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneProcessTest.kt @@ -0,0 +1,449 @@ +package ai.rever.boss.git + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private const val SHORT_TIMEOUT_MILLIS = 100L +private const val NORMAL_TIMEOUT_MILLIS = 5_000L +private const val CANCELLATION_TIMEOUT_MILLIS = 30_000L +private const val READER_START_TIMEOUT_SECONDS = 5L +private const val TEST_TIMEOUT_SECONDS = 15L + +@Timeout(TEST_TIMEOUT_SECONDS) +class GitCloneProcessTest { + @Test + fun `silent output times out and releases the process reader and partial destination`( + @TempDir tempDirectory: File, + ) = runBlocking { + val output = SilentInputStream() + val process = ControlledProcess(processOutput = output) + val cleanupCalls = AtomicInteger() + val partialClone = + File(tempDirectory, "partial-clone").apply { + assertTrue(mkdirs()) + File(this, "partial-file").writeText("partial") + } + + assertFailsWith { + runCloneProcess( + process = process, + timeoutMillis = SHORT_TIMEOUT_MILLIS, + onOutputLine = {}, + onCancellation = { + cleanupCalls.incrementAndGet() + partialClone.deleteRecursively() + }, + ) + } + + assertFalse(process.isAlive, "the timed-out process must be terminated") + assertTrue(process.wasForciblyDestroyed, "timeout must force process termination") + assertTrue(output.wasClosed, "timeout must close the process output stream") + assertTrue(output.readFinished, "the blocked output reader must finish") + assertEquals(1, cleanupCalls.get(), "partial-clone cleanup must run exactly once") + assertFalse(partialClone.exists(), "the partial clone destination must be removed") + } + + @Test + fun `caller cancellation terminates a silent process and propagates cancellation`() = + runBlocking { + val output = SilentInputStream() + val process = ControlledProcess(processOutput = output) + val cleanupCalls = AtomicInteger() + + val operation = + async(Dispatchers.Default) { + runCloneProcess( + process = process, + timeoutMillis = CANCELLATION_TIMEOUT_MILLIS, + onOutputLine = {}, + onCancellation = { + cleanupCalls.incrementAndGet() + }, + ) + } + + assertTrue( + output.awaitReaderStarted(), + "the output reader did not enter the silent read", + ) + + operation.cancel() + + assertFailsWith { + operation.await() + } + + assertFalse(process.isAlive, "the cancelled process must be terminated") + assertTrue(process.wasForciblyDestroyed, "cancellation must force process termination") + assertTrue(output.wasClosed, "cancellation must close the process output stream") + assertTrue(output.readFinished, "the blocked output reader must finish") + assertEquals(1, cleanupCalls.get(), "partial-clone cleanup must run exactly once") + } + + @Test + fun `successful process streams every line and returns zero`() = + runBlocking { + val process = + ControlledProcess( + processOutput = + ByteArrayInputStream( + "Cloning repository...\nReceiving objects: 100%\n" + .toByteArray(Charsets.UTF_8), + ), + exitCode = 0, + initiallyExited = true, + ) + val lines = mutableListOf() + val cleanupCalls = AtomicInteger() + + val exitCode = + runCloneProcess( + process = process, + timeoutMillis = NORMAL_TIMEOUT_MILLIS, + onOutputLine = lines::add, + onCancellation = { + cleanupCalls.incrementAndGet() + }, + ) + + assertEquals(0, exitCode) + assertEquals( + listOf("Cloning repository...", "Receiving objects: 100%"), + lines, + ) + assertFalse(process.wasForciblyDestroyed) + assertEquals(0, cleanupCalls.get()) + } + + @Test + fun `nonzero process exit is returned without cancellation cleanup`() = + runBlocking { + val process = + ControlledProcess( + processOutput = + ByteArrayInputStream( + "fatal: repository not found\n".toByteArray(Charsets.UTF_8), + ), + exitCode = 128, + initiallyExited = true, + ) + val lines = mutableListOf() + val cleanupCalls = AtomicInteger() + + val exitCode = + runCloneProcess( + process = process, + timeoutMillis = NORMAL_TIMEOUT_MILLIS, + onOutputLine = lines::add, + onCancellation = { + cleanupCalls.incrementAndGet() + }, + ) + + assertEquals(128, exitCode) + assertEquals(listOf("fatal: repository not found"), lines) + assertFalse(process.wasForciblyDestroyed) + assertEquals(0, cleanupCalls.get()) + } + + @Test + fun `successful exit remains authoritative when an entered progress callback is late`() = + runBlocking { + val process = + ControlledProcess( + processOutput = + ByteArrayInputStream( + "late progress\n".toByteArray(Charsets.UTF_8), + ), + exitCode = 0, + initiallyExited = true, + ) + val callbackEntered = CountDownLatch(1) + val neverReleased = CountDownLatch(1) + val outputClosed = AtomicBoolean() + val callbackSettled = AtomicBoolean() + val outputFailure = AtomicReference() + + val exitCode = + runCloneProcess( + process = process, + timeoutMillis = NORMAL_TIMEOUT_MILLIS, + onOutputLine = { + callbackEntered.countDown() + try { + neverReleased.await() + } finally { + callbackSettled.set(true) + assertFalse(outputClosed.get(), "callback must settle before output closes") + } + }, + onCancellation = {}, + outputLifecycle = + CloneOutputLifecycle( + onClosed = { + outputClosed.set(true) + }, + onFailure = outputFailure::set, + ), + ) + + assertEquals(0, exitCode, "the successful Git exit must remain authoritative") + assertTrue(callbackEntered.count == 0L, "the progress callback was never entered") + assertTrue(outputClosed.get(), "terminal completion must close progress publication") + assertTrue(callbackSettled.get(), "callback must settle before the operation returns") + assertTrue( + outputFailure.get() is TimeoutCancellationException, + "the delayed reader should be reported as an advisory output failure", + ) + assertFalse(process.wasForciblyDestroyed, "a successful process must not be treated as failed") + } + + @Test + fun `cancellation interrupts an entered callback before cleanup returns`() = + runBlocking { + val process = ControlledProcess(ByteArrayInputStream("progress\n".toByteArray())) + val entered = CountDownLatch(1) + val settled = AtomicBoolean() + val cleanupCalls = AtomicInteger() + val operation = + async(Dispatchers.Default) { + runCloneProcess( + process, + CANCELLATION_TIMEOUT_MILLIS, + onOutputLine = { + entered.countDown() + try { + CountDownLatch(1).await() + } finally { + settled.set(true) + } + }, + onCancellation = { cleanupCalls.incrementAndGet() }, + ) + } + assertTrue(entered.await(READER_START_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + operation.cancel() + assertFailsWith { operation.await() } + assertTrue(settled.get(), "no entered callback may survive operation completion") + assertFalse(process.isAlive) + assertEquals(1, cleanupCalls.get()) + } + + @Test + fun `process exit interrupts an entered callback and preserves success`() = + runBlocking { + val process = ControlledProcess(ByteArrayInputStream("progress\n".toByteArray())) + val entered = CountDownLatch(1) + val settled = AtomicBoolean() + val operation = + async(Dispatchers.Default) { + runCloneProcess(process, CANCELLATION_TIMEOUT_MILLIS, { + entered.countDown() + try { + CountDownLatch(1).await() + } finally { + settled.set(true) + } + }, {}) + } + assertTrue(entered.await(READER_START_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + process.destroy() // ControlledProcess models a normal exit here. + assertEquals(0, operation.await()) + assertTrue(settled.get()) + assertFalse(process.wasForciblyDestroyed) + } + + @Test + fun `progress callback failure is advisory and preserves a successful clone`() = + runBlocking { + val process = ControlledProcess(ByteArrayInputStream("progress\n".toByteArray())) + val observed = CountDownLatch(1) + val failures = AtomicInteger() + val cleanupCalls = AtomicInteger() + val operation = + async(Dispatchers.Default) { + runCloneProcess( + process, + CANCELLATION_TIMEOUT_MILLIS, + onOutputLine = { throw IOException("progress callback failed") }, + onCancellation = { cleanupCalls.incrementAndGet() }, + outputLifecycle = + CloneOutputLifecycle(onFailure = { + failures.incrementAndGet() + observed.countDown() + }), + ) + } + assertTrue(observed.await(READER_START_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + assertTrue(process.isAlive, "an advisory callback fault must not kill Git") + process.destroy() + assertEquals(0, operation.await()) + assertEquals(1, failures.get()) + assertEquals(0, cleanupCalls.get()) + assertFalse(process.wasForciblyDestroyed) + } + + @Test + fun `pipe failure after actual success preserves exit when notification is delayed`() = + runBlocking { + val process = + ControlledProcess( + processOutput = + object : InputStream() { + override fun read(): Int = throw IOException("pipe closed after exit") + }, + initiallyExited = true, + deferExitNotification = true, + ) + val cleanups = AtomicInteger() + val exitCode = runCloneProcess(process, NORMAL_TIMEOUT_MILLIS, {}, { cleanups.incrementAndGet() }) + assertEquals(0, exitCode) + assertEquals(0, cleanups.get()) + assertFalse(process.wasForciblyDestroyed) + } + + @Test + fun `reader failure aborts through central cleanup`() = + runBlocking { + val process = + ControlledProcess( + object : InputStream() { + override fun read(): Int = throw IOException("pipe failed") + }, + ) + val cleanups = AtomicInteger() + assertFailsWith { + runCloneProcess(process, NORMAL_TIMEOUT_MILLIS, {}, { cleanups.incrementAndGet() }) + } + assertFalse(process.isAlive) + assertEquals(1, cleanups.get()) + } +} + +private class ControlledProcess( + private val processOutput: InputStream, + private val exitCode: Int = 0, + initiallyExited: Boolean = false, + private val deferExitNotification: Boolean = false, +) : Process() { + private val exitFuture = CompletableFuture() + private val alive = AtomicBoolean(!initiallyExited) + private val processInput = ByteArrayOutputStream() + private val processError = ByteArrayInputStream(ByteArray(0)) + private val forciblyDestroyed = AtomicBoolean() + + val wasForciblyDestroyed: Boolean + get() = forciblyDestroyed.get() + + init { + if (initiallyExited) { + exitFuture.complete(this) + } + } + + override fun getOutputStream(): OutputStream = processInput + + override fun getInputStream(): InputStream = processOutput + + override fun getErrorStream(): InputStream = processError + + override fun waitFor(): Int { + exitFuture.get() + return exitCode + } + + override fun waitFor( + timeout: Long, + unit: TimeUnit, + ): Boolean = + try { + exitFuture.get(timeout, unit) + true + } catch (_: TimeoutException) { + false + } + + override fun exitValue(): Int { + if (alive.get()) { + throw IllegalThreadStateException("Process has not exited") + } + return exitCode + } + + override fun destroy() { + finish() + } + + override fun destroyForcibly(): Process { + forciblyDestroyed.set(true) + finish() + return this + } + + override fun isAlive(): Boolean = alive.get() + + override fun onExit(): CompletableFuture = if (deferExitNotification) CompletableFuture() else exitFuture + + private fun finish() { + alive.set(false) + exitFuture.complete(this) + } +} + +private class SilentInputStream : InputStream() { + private val readerStarted = CountDownLatch(1) + private val closedSignal = CountDownLatch(1) + private val readerFinished = CountDownLatch(1) + + @Volatile + var wasClosed: Boolean = false + private set + + val readFinished: Boolean + get() = readerFinished.count == 0L + + override fun read(): Int { + readerStarted.countDown() + return try { + closedSignal.await() + -1 + } finally { + readerFinished.countDown() + } + } + + override fun close() { + wasClosed = true + closedSignal.countDown() + } + + fun awaitReaderStarted(): Boolean = + readerStarted.await( + READER_START_TIMEOUT_SECONDS, + TimeUnit.SECONDS, + ) +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneProcessTreeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneProcessTreeTest.kt new file mode 100644 index 0000000000..07c308c895 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneProcessTreeTest.kt @@ -0,0 +1,313 @@ +package ai.rever.boss.git + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.io.IOException +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private const val TREE_READY_PREFIX = "TREE_READY:" +private const val TREE_READY_TIMEOUT_SECONDS = 10L +private const val TREE_PROCESS_TIMEOUT_MILLIS = 5_000L +private const val TREE_CANCELLATION_TIMEOUT_MILLIS = 30_000L +private const val TREE_TEST_TIMEOUT_SECONDS = 60L + +@Timeout(TREE_TEST_TIMEOUT_SECONDS) +class GitCloneProcessTreeTest { + @Test + fun `deadline still terminates the real tree after an advisory callback failure`( + @TempDir tempDirectory: Path, + ) = runBlocking { + val process = startProcessTree(tempDirectory) + val helperPid = AtomicLong(-1L) + val cleanupCalls = AtomicInteger() + + try { + val failures = AtomicInteger() + assertFailsWith { + runCloneProcess( + process = process, + timeoutMillis = TREE_PROCESS_TIMEOUT_MILLIS, + onOutputLine = { line -> + captureHelperPid(line, helperPid) + if (line.startsWith(TREE_READY_PREFIX)) { + throw IOException("forced progress callback failure") + } + }, + onCancellation = { + cleanupCalls.incrementAndGet() + }, + outputLifecycle = CloneOutputLifecycle(onFailure = { failures.incrementAndGet() }), + ) + } + + assertEquals(1, failures.get()) + assertEquals(1, cleanupCalls.get()) + assertTreeStopped(process, helperPid) + } finally { + killTestProcessTree(process, helperPid.get()) + } + } + + @Test + fun `timeout terminates the real parent and helper process`( + @TempDir tempDirectory: Path, + ) = runBlocking { + val process = startProcessTree(tempDirectory) + val helperPid = AtomicLong(-1L) + val cleanupCalls = AtomicInteger() + + try { + assertFailsWith { + runCloneProcess( + process = process, + timeoutMillis = TREE_PROCESS_TIMEOUT_MILLIS, + onOutputLine = { line -> + captureHelperPid(line, helperPid) + }, + onCancellation = { + cleanupCalls.incrementAndGet() + }, + ) + } + + assertEquals(1, cleanupCalls.get()) + assertTreeStopped(process, helperPid) + } finally { + killTestProcessTree(process, helperPid.get()) + } + } + + @Test + fun `caller cancellation terminates the real parent and helper process`( + @TempDir tempDirectory: Path, + ) = runBlocking { + val process = startProcessTree(tempDirectory) + val helperPid = AtomicLong(-1L) + val treeReady = CountDownLatch(1) + val cleanupCalls = AtomicInteger() + + try { + val operation = + async(Dispatchers.Default) { + runCloneProcess( + process = process, + timeoutMillis = TREE_CANCELLATION_TIMEOUT_MILLIS, + onOutputLine = { line -> + captureHelperPid(line, helperPid) + if (line.startsWith(TREE_READY_PREFIX)) { + treeReady.countDown() + } + }, + onCancellation = { + cleanupCalls.incrementAndGet() + }, + ) + } + + assertTrue( + treeReady.await(TREE_READY_TIMEOUT_SECONDS, TimeUnit.SECONDS), + "the real process tree did not become ready", + ) + + operation.cancel() + + assertFailsWith { + operation.await() + } + + assertEquals(1, cleanupCalls.get()) + assertTreeStopped(process, helperPid) + } finally { + killTestProcessTree(process, helperPid.get()) + } + } + + private fun startProcessTree(tempDirectory: Path): Process { + val classpath = currentTestClasspath() + val launcherPrefix = + "-cp\n${quoteLauncherArgument(classpath)}\n" + + "${GitCloneProcessTreeProbe::class.java.name}\n" + + val helperArguments = + Files.createTempFile( + tempDirectory, + "git-clone-helper-", + ".args", + ) + Files.writeString( + helperArguments, + "${launcherPrefix}helper\n", + ) + + val parentArguments = + Files.createTempFile( + tempDirectory, + "git-clone-parent-", + ".args", + ) + Files.writeString( + parentArguments, + "${launcherPrefix}parent\n" + + "${quoteLauncherArgument(helperArguments.toAbsolutePath().toString())}\n", + ) + + val java = + File( + System.getProperty("java.home"), + "bin/java", + ).absolutePath + + return ProcessBuilder( + java, + "@${parentArguments.toAbsolutePath()}", + ).redirectErrorStream(true) + .start() + } + + private fun currentTestClasspath(): String { + val urls = + generateSequence(javaClass.classLoader) { it.parent } + .filterIsInstance() + .flatMap { it.getURLs().asSequence() } + .map { File(it.toURI()).path } + .toList() + + return (urls + System.getProperty("java.class.path").split(File.pathSeparator)) + .distinct() + .joinToString(File.pathSeparator) + } + + private fun quoteLauncherArgument(value: String): String { + val escaped = + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + return "\"$escaped\"" + } + + private fun captureHelperPid( + line: String, + helperPid: AtomicLong, + ) { + if (line.startsWith(TREE_READY_PREFIX)) { + helperPid.compareAndSet( + -1L, + line.removePrefix(TREE_READY_PREFIX).trim().toLong(), + ) + } + } + + private fun assertTreeStopped( + process: Process, + helperPid: AtomicLong, + ) { + assertFalse(process.isAlive, "the real parent process must be terminated") + + val capturedPid = helperPid.get() + assertTrue(capturedPid > 0L, "the helper process PID was not captured") + + val helper = ProcessHandle.of(capturedPid).orElse(null) + assertTrue( + helper == null || !helper.isAlive, + "the real helper process must not survive cleanup", + ) + } + + private fun killTestProcessTree( + process: Process, + helperPid: Long, + ) { + val descendants = + runCatching { + process + .toHandle() + .descendants() + .use { it.toList() } + }.getOrDefault(emptyList()) + + descendants.forEach { descendant -> + runCatching { + if (descendant.isAlive) { + descendant.destroyForcibly() + } + } + } + + if (helperPid > 0L) { + ProcessHandle.of(helperPid).ifPresent { helper -> + runCatching { + if (helper.isAlive) { + helper.destroyForcibly() + } + } + } + } + + runCatching { + if (process.isAlive) { + process.destroyForcibly() + } + } + runCatching { + process.waitFor(5L, TimeUnit.SECONDS) + } + } +} + +object GitCloneProcessTreeProbe { + @JvmStatic + fun main(args: Array) { + when (args.firstOrNull()) { + "helper" -> stayAlive() + "parent" -> runParent(requireNotNull(args.getOrNull(1))) + else -> error("Unknown process-tree probe mode") + } + } + + private fun runParent(helperArgumentsFile: String) { + val java = + File( + System.getProperty("java.home"), + "bin/java", + ).absolutePath + val helper = + ProcessBuilder( + java, + "@$helperArgumentsFile", + ).inheritIO() + .start() + + try { + println("$TREE_READY_PREFIX${helper.pid()}") + System.out.flush() + stayAlive() + } finally { + if (helper.isAlive) { + helper.destroyForcibly() + } + } + } + + private fun stayAlive(): Nothing { + while (true) { + Thread.sleep(TimeUnit.MINUTES.toMillis(1L)) + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneRepositoryLifecycleTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneRepositoryLifecycleTest.kt new file mode 100644 index 0000000000..8557a5b09a --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/git/GitCloneRepositoryLifecycleTest.kt @@ -0,0 +1,268 @@ +package ai.rever.boss.git + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.io.TempDir +import java.io.Closeable +import java.io.File +import java.net.InetAddress +import java.net.ServerSocket +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import ai.rever.boss.plugin.git.GitOperationResult.Error as GitError +import ai.rever.boss.plugin.git.GitOperationResult.Success as GitSuccess + +private const val PUBLIC_CLONE_TIMEOUT_MILLIS = 5_000L +private const val PUBLIC_CLONE_LONG_TIMEOUT_MILLIS = 30_000L +private const val PUBLIC_CLONE_WAIT_SECONDS = 10L +private const val PUBLIC_CLONE_TEST_TIMEOUT_SECONDS = 45L + +@Timeout(PUBLIC_CLONE_TEST_TIMEOUT_SECONDS) +class GitCloneRepositoryLifecycleTest { + @Test + fun `public clone timeout removes its partial destination and permits retry`( + @TempDir tempDirectory: Path, + ) = runBlocking { + val target = tempDirectory.resolve("timeout-target").toFile() + + val result = + StalledHttpServer().use { server -> + val operation = + async(Dispatchers.Default) { + GitService.cloneRepositoryWithTimeout( + repositoryUrl = server.repositoryUrl, + targetDirectory = target.absolutePath, + onProgress = {}, + timeoutMillis = PUBLIC_CLONE_TIMEOUT_MILLIS, + ) + } + + assertTrue( + server.awaitConnection(), + "Git did not connect to the stalled HTTP server", + ) + assertTrue( + awaitTargetCreation(target), + "Git did not create its partial clone destination", + ) + + operation.await() + } + + assertTrue(result is GitError, "timeout must return a Git error: $result") + assertTrue(result.message.contains("timed out"), "timeout must use the timeout error mapping") + assertFalse(target.exists(), "timeout must remove the partial destination") + assertRetrySucceeds(tempDirectory, target) + } + + @Test + fun `public clone cancellation removes its partial destination and permits retry`( + @TempDir tempDirectory: Path, + ) = runBlocking { + val target = tempDirectory.resolve("cancellation-target").toFile() + + StalledHttpServer().use { server -> + val operation = + async(Dispatchers.Default) { + GitService.cloneRepositoryWithTimeout( + repositoryUrl = server.repositoryUrl, + targetDirectory = target.absolutePath, + onProgress = {}, + timeoutMillis = PUBLIC_CLONE_LONG_TIMEOUT_MILLIS, + ) + } + + assertTrue( + server.awaitConnection(), + "Git did not connect to the stalled HTTP server", + ) + assertTrue( + awaitTargetCreation(target), + "Git did not create its partial clone destination", + ) + + operation.cancel() + + assertFailsWith { + operation.await() + } + } + + assertFalse(target.exists(), "cancellation must remove the partial destination") + assertRetrySucceeds(tempDirectory, target) + } + + // Adapted from Antriksh1984's independent #659 regression coverage. + @Test + fun `pre-existing target is refused without deleting its contents`( + @TempDir tempDirectory: Path, + ) = runBlocking { + val target = tempDirectory.resolve("existing").toFile().apply { mkdirs() } + val marker = File(target, "keep.txt").apply { writeText("user data") } + val result = GitService.cloneRepository("https://example.invalid/repo.git", target.absolutePath) {} + assertTrue(result is GitError) + assertEquals("user data", marker.readText()) + } + + // Adapted from Antriksh1984's independent #659 nonzero-exit regression. + @Test + fun `nonexistent source returns an ordinary clone error`( + @TempDir tempDirectory: Path, + ) = runBlocking { + val result = + GitService.cloneRepositoryWithTimeout( + tempDirectory.resolve("missing").toString(), + tempDirectory.resolve("target").toString(), + {}, + PUBLIC_CLONE_TIMEOUT_MILLIS, + ) + assertTrue(result is GitError) + } + + private suspend fun assertRetrySucceeds( + tempDirectory: Path, + target: File, + ) { + val source = createLocalRepository(tempDirectory.resolve("retry-source")) + + val progress = mutableListOf() + val result = + GitService.cloneRepository( + repositoryUrl = source.absolutePath, + targetDirectory = target.absolutePath, + onProgress = progress::add, + ) + + assertEquals("Clone completed successfully", progress.last()) + assertTrue(progress.contains("Cloning repository...")) + assertTrue(result is GitSuccess, "retry must succeed after cleanup: $result") + assertTrue( + File(target, "README.md").isFile, + "the successful retry did not produce the committed file", + ) + } + + private fun createLocalRepository(path: Path): File { + Files.createDirectories(path) + val repository = path.toFile() + + runGit(repository, "init", "-q") + File(repository, "README.md").writeText("clone retry fixture\n") + runGit(repository, "add", "README.md") + runGit( + repository, + "-c", + "user.name=BOSS Test", + "-c", + "user.email=boss-test@example.invalid", + "commit", + "-q", + "-m", + "Initial fixture", + ) + + return repository + } + + private fun runGit( + directory: File, + vararg arguments: String, + ) { + val process = + ProcessBuilder(listOf("git") + arguments) + .directory(directory) + .redirectErrorStream(true) + .start() + + val completed = process.waitFor(20L, TimeUnit.SECONDS) + if (!completed) { + process.destroyForcibly() + } + + assertTrue( + completed, + "Git fixture command timed out: ${arguments.joinToString(" ")}", + ) + + val output = + process.inputStream + .bufferedReader() + .use { it.readText() } + + assertEquals( + 0, + process.exitValue(), + "Git fixture command failed: ${arguments.joinToString(" ")}\n$output", + ) + } + + private fun awaitTargetCreation(target: File): Boolean { + val deadline = + System.nanoTime() + + TimeUnit.SECONDS.toNanos(PUBLIC_CLONE_WAIT_SECONDS) + + while (System.nanoTime() < deadline) { + if (target.exists()) { + return true + } + Thread.sleep(25L) + } + + return target.exists() + } +} + +private class StalledHttpServer : Closeable { + private val server = + ServerSocket( + 0, + 1, + InetAddress.getByName("127.0.0.1"), + ) + private val connectionAccepted = CountDownLatch(1) + private val releaseConnection = CountDownLatch(1) + private val serverThread = + Thread( + { + runCatching { + server.accept().use { _ -> + connectionAccepted.countDown() + releaseConnection.await() + } + } + }, + "boss-git-clone-stalled-http", + ).apply { + isDaemon = true + start() + } + + val repositoryUrl: String = + "http://127.0.0.1:${server.localPort}/ordinary.git" + + fun awaitConnection(): Boolean = + connectionAccepted.await( + PUBLIC_CLONE_WAIT_SECONDS, + TimeUnit.SECONDS, + ) + + override fun close() { + releaseConnection.countDown() + runCatching { server.close() } + runCatching { + serverThread.join( + TimeUnit.SECONDS.toMillis(PUBLIC_CLONE_WAIT_SECONDS), + ) + } + } +} From 9abb47819a7853cfd889cdb1977f299f314322d6 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Tue, 15 Sep 2026 06:17:09 +0530 Subject: [PATCH 17/49] fix(kernel): require verified caller identity for the split-view bridge (#672) * 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. * test(kernel): verify split-view identity coverage and clarify scope --------- Co-authored-by: Shivang --- .../kernel/services/SplitViewServiceBridge.kt | 70 ++++ .../services/SplitViewServiceBridgeTest.kt | 367 ++++++++++++++++++ 2 files changed, 437 insertions(+) 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/SplitViewServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt index d089262156..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 @@ -1,55 +1,125 @@ 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 +/** + * Host-side split-view operations for authenticated child processes (BossConsole#53). + * + * 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") 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") + // 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 + * 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..14393669bd --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt @@ -0,0 +1,367 @@ +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 +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.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 +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 val exercisedRpcs = java.util.Collections.synchronizedSet(mutableSetOf()) + 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)) + .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 = + 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") + assertEquals( + SplitViewServiceBridge(provider) + .bindService() + .methods + .map { it.methodDescriptor.fullMethodName } + .toSet(), + exercisedRpcs.toSet(), + "Every declared RPC must be exercised by the refusal test", + ) + } + + @Test + fun `an authenticated caller can open a URL, files and position`() = + runBlocking { + authenticated.openUrlInActivePanel( + SplitViewOpenUrlRequest + .newBuilder() + .setUrl("https://example.com") + .setTitle("t") + .setForceNewTab(true) + .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|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, + ) + } + + @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|p1", "preserveCurrentState|w1|n", "selectTabInPanel|t1|p1"), + 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()) + } + + @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) + } + + /** 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|$url|$title|$forceNewTab" + } + + override fun openFileInActivePanel( + filePath: String, + fileName: String, + ) { + calls += "openFileInActivePanel|$filePath|$fileName" + } + + override fun openFileInBrowser( + filePath: String, + fileName: String, + ) { + calls += "openFileInBrowser|$filePath|$fileName" + } + + override fun openFileInEditor( + filePath: String, + fileName: String, + ) { + calls += "openFileInEditor|$filePath|$fileName" + } + + override fun openFileAtPosition( + filePath: String, + fileName: String, + line: Int, + column: Int, + ) { + calls += "openFileAtPosition|$filePath|$fileName|$line|$column" + } + + override fun setActivePanel(panelId: String) { + calls += "setActivePanel|$panelId" + } + + override fun preserveCurrentState( + workspaceId: String, + workspaceName: String, + ) { + calls += "preserveCurrentState|$workspaceId|$workspaceName" + } + + override fun getActiveTabsComponent(): TabsComponent? = null + + override fun applyWorkspace(workspace: LayoutWorkspace) = Unit + + override fun selectTabInPanel( + tabId: String, + panelId: String, + ) { + calls += "selectTabInPanel|$tabId|$panelId" + } + + 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 174ab450b91cd5bf3e373c4f0d06267e0fd6a6cd Mon Sep 17 00:00:00 2001 From: Antriksh Date: Tue, 15 Sep 2026 06:40:47 +0530 Subject: [PATCH 18/49] feat(mcp): let operators set tool policy proactively (#636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(search): index open tabs by their URL and file path (#606) (#648) Global Search matched an open tab only against its title, and TabResult hardcoded url = null / filePath = null on every hit. A browser tab whose title never mentions its domain, or an editor tab someone wants to find by path rather than filename, could not be found at all. searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and an editor tab's EditorTabInfo.filePath, through the same substring- containment gate (proseScore) searchRecentPages already uses for a page's URL, since a fuzzy subsequence match over a whole URL or path is close to meaningless. TabResult carries the real url/filePath instead of null, and the tab row's subtitle now leads with the workspace name (disambiguating identical URLs open in different workspaces, or across windows) followed by the url/filePath/tabType, in place of the old "tabType • workspaceName" line. 11 tests cover: URL-only and file-path-only matches, both fields populated for the right tab type and left null for the other/a blank path, a short query not matching a URL by fuzzy accident, title+URL both matching taking the higher (title) score with title highlight ranges preserved, live navigation changing which URL matches (and the old URL no longer matching), directory-path matching without a subsequence false-positive, and no-open-tabs contributing nothing rather than failing. * fix(browser): replace superseded capture requests (#509) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and buil… * fix(kernel): require verified caller identity for the active-tabs bridge (#646) Every ActiveTabsService RPC was reachable by any process able to open a connection to the kernel IPC server, not only the plugins the host itself loaded. watchActiveTabs disclosed every open tab's title and URL across every window with no credential at all, and createBrowserTab could force the browser to navigate to an arbitrary URL; selectTab/closeTab could manipulate what the user is looking at. All seven RPCs now require a verified caller identity, matching the pattern PR #505 established for the Secret Service: unary RPCs call authenticatedCallerOrRefuse, and the watchActiveTabs stream captures ProcessIdentityInterceptor.CURRENT_IDENTITY synchronously in the method body (before the flow is collected, on the calling thread the interceptor's context is current on) and re-checks it before every emission, so a revoked caller's stream is refused before an already-held handle could receive a value it should no longer see. Refusals are logged with a bind/revocation stage tag, and the three mutating RPCs (selectTab, createBrowserTab, closeTab) now record an attributed audit line - caller and RPC name only, no URLs or titles. Tested with a real gRPC server plus ProcessIdentityInterceptor: every RPC is refused with no credential and never reaches the provider, an authenticated caller can watch/read/select/create/close tabs, and a revoked caller's live watch stream terminates with PERMISSION_DENIED before receiving a value that arrived after revocation. * :arrow_up: deps:(deps): bump essenty from 2.5.0 to 2.6.0 (#655) Bumps `essenty` from 2.5.0 to 2.6.0. Updates `com.arkivanov.essenty:lifecycle` from 2.5.0 to 2.6.0 - [Release notes](https://github.com/arkivanov/Essenty/releases) - [Commits](https://github.com/arkivanov/Essenty/compare/2.5.0...2.6.0) Updates `com.arkivanov.essenty:state-keeper` from 2.5.0 to 2.6.0 - [Release notes](https://github.com/arkivanov/Essenty/releases) - [Commits](https://github.com/arkivanov/Essenty/compare/2.5.0...2.6.0) --- updated-dependencies: - dependency-name: com.arkivanov.essenty:lifecycle dependency-version: 2.6.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.arkivanov.essenty:state-keeper dependency-version: 2.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * :arrow_up: deps:(deps): bump org.graalvm.buildtools.native (#656) Bumps [org.graalvm.buildtools.native](https://github.com/graalvm/native-build-tools) from 1.1.11 to 1.1.12. - [Release notes](https://github.com/graalvm/native-build-tools/releases) - [Commits](https://github.com/graalvm/native-build-tools/compare/1.1.11...1.1.12) --- updated-dependencies: - dependency-name: org.graalvm.buildtools.native dependency-version: 1.1.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(mcp): let operators set MCP tool policy proactively Reactive approval was the only way a persistent tool policy came to exist: a plugin had to invoke the tool and trigger the approval dialog before "Always Allow"/"Always Deny" could be clicked. An operator who already knew they never wanted a tool run had no way to say so ahead of time, short of hand-editing ~/.boss/mcp-tool-policy.json. "Persisted MCP policies" (present even with zero saved rules, then labeled "Set MCP tool policies") now also offers a rule for any registered, not-yet-ruled, not-disabled tool - Allow requires a second confirming tap and shows the tool's risk assessment first, same as the approval dialog's own Always Allow. The write goes through a new McpPolicyEngine.setToolPolicyIfAbsent, not the reactive path's setToolPolicy: the proactive contract is "add only while this tool still has none of its own," and the existing expectedRevocation guard alone can't enforce that - revocationVersion only moves on a revoke, so an intervening explicit ASK or ALLOW made through the reactive approval dialog for the same tool never trips it, and a candidate captured while the tool had no rule could otherwise silently overwrite a decision made in between. setToolPolicyIfAbsent re-checks the rule map under the same lock the write itself takes, so any rule present at write time - not only a DENY - refuses the write, and returns a result distinguishing that refusal from a genuine disk failure rather than a bare boolean. mcpProactivePolicyCandidates filters the offered list to tools with no rule and not kill-switch-disabled, stamping each with its own revocation generation for the write to pass back. * fix(mcp): preserve policy guards and refresh proactive decisions * fix(mcp): explain stable policy refusals and bound narrow dialogs --------- Signed-off-by: dependabot[bot] Co-authored-by: Shikhar Goel Co-authored-by: Nilesh Vasita Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Shivang Co-authored-by: Shivanshu Co-authored-by: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Co-authored-by: Patel Karma Rohit Co-authored-by: Aditya8369 Co-authored-by: passionate-coder26 Co-authored-by: rehannayeem0786 Co-authored-by: rehannayeem0786 <128595058+rehannayeem0786@users.noreply.github.com> Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shwetas Dhake Co-authored-by: AdityaK-iiita Co-authored-by: Oleksii (Lexi) Kyrylchuk Co-authored-by: Sahvendra Co-authored-by: Aishwary Anand <201525941+Aishwary11-11@users.noreply.github.com> Co-authored-by: Aishwary Anand Co-authored-by: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Co-authored-by: ayush Co-authored-by: Anshul Pundhir <129892939+pundhiranshul@users.noreply.github.com> Co-authored-by: Sanjay Co-authored-by: arjun28115 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Achintya Chaurasia Co-authored-by: surfthewave-commits Co-authored-by: Ayushman <139611211+InfoSage05@users.noreply.github.com> Co-authored-by: Kanak Khandelwal <152528927+Kanakk23@users.noreply.github.com> Co-authored-by: Ishaan Chowdhury Co-authored-by: Karan Raj Co-authored-by: john_cybersage Co-authored-by: aryanbajaj868 <117334799+aryanbajaj868@users.noreply.github.com> Co-authored-by: Risa Labs Co-authored-by: john k Co-authored-by: kshivang Co-authored-by: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Co-authored-by: pundhiranshul Co-authored-by: Jaspreet Singh --- AGENTS.md | 22 + .../bars/horizontal/BossBottomBar.kt | 138 +++++-- .../dialogs/McpPolicyManagerDialog.kt | 380 ++++++++++++++---- .../ai/rever/boss/mcp/McpPolicyEngine.kt | 84 +++- .../McpProactivePolicyCandidatesTest.kt | 114 ++++++ .../dialogs/McpProactivePolicyDialogTest.kt | 213 ++++++++++ .../mcp/McpPolicyEngineAddOnlyIfAbsentTest.kt | 197 +++++++++ 7 files changed, 1043 insertions(+), 105 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/components/bars/horizontal/McpProactivePolicyCandidatesTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpPolicyEngineAddOnlyIfAbsentTest.kt diff --git a/AGENTS.md b/AGENTS.md index f44145d8c8..842c0c2cb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2083,3 +2083,25 @@ no second sandbox prompt. Explicit policies and session trust retain precedence. HIGH/CRITICAL names use the mutating default, while unknown names remain allowed by default. Risk reasons and sanitized arguments appear together in the existing approval dialog. #362 is closed pending extraction into a management plugin. + +**The "Persisted MCP policies" bottom bar button also lets an operator set a rule +*proactively*, for a registered tool without a saved rule.** It is present even with zero saved +rules (labeled "Set MCP tool policies" then). Allow requires a second confirming tap +and shows the tool's risk assessment first, the same way the approval dialog's own +"Always Allow" does, since it is the same durable, tool-name-wide grant. The write goes +through `McpPolicyEngine.setToolPolicyIfAbsent`, not the reactive approval path's +`setToolPolicy` - the proactive contract is "add a rule only while this tool still has +none of its own," and `expectedRevocation`/`providerId` (captured when the tool was +offered as a candidate, via `mcpProactivePolicyCandidates` / +`McpToolIdentity.expectedRevocation`) alone cannot enforce that: `revocationVersion` +only moves on a revoke, so an intervening explicit ASK or ALLOW made through the +reactive approval dialog for this same tool never trips it, and a `preserveDeny`-style +guard would let the proactive write silently clobber that decision. `setToolPolicyIfAbsent` +re-checks `toolName !in rules` under the same lock the write itself takes, atomically, so +any rule present at write time - not only a DENY - refuses the write instead. The same +lock also refuses provider DENY and unreadable-policy faults, preserving damaged files +for manual recovery. Refused writes refresh candidates and require a fresh confirmation; +storage failures get separate feedback. Stable DENY and damaged-file refusals are explained +inside the dialog, including backup/recovery guidance. Confirmation is tied to the full candidate snapshot. +These privileged writes remain beside host policy enforcement. #416 tracks the separate +observation/plugin architecture; this PR does not expose a policy writer to plugins. diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt index df5c869ad8..7b1833135f 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt @@ -7,6 +7,7 @@ import ai.rever.boss.components.bars.rememberBarContextMenuItems import ai.rever.boss.components.buttons.BossActionButton import ai.rever.boss.components.dialogs.McpPolicyManagerDialog import ai.rever.boss.components.dialogs.McpProviderTrustDialog +import ai.rever.boss.components.dialogs.McpToolIdentity import ai.rever.boss.components.events.PanelEventBus import ai.rever.boss.components.overlays.contextMenu import ai.rever.boss.components.plugin.registries.StatusBarRegistryImpl @@ -19,6 +20,7 @@ import ai.rever.boss.mcp.McpToolPolicyConfig import ai.rever.boss.mcp.McpToolRegistryImpl import ai.rever.boss.performance.PerformanceState import ai.rever.boss.plugin.api.PanelId +import ai.rever.boss.plugin.api.RegisteredMcpTool import ai.rever.boss.plugin.api.StatusBarAlignment import ai.rever.boss.plugin.sandbox.ui.PluginExtensionBoundary import ai.rever.boss.plugin.tab.codeeditor.EditorTabInfo @@ -239,34 +241,13 @@ fun BossRightBottomBar() { } // Inspection/revocation for a rule saved via the approval dialog's "Always Allow"/"Always - // Deny" - the gap AGENTS.md's governance section names as reachable only by hand-editing - // ~/.boss/mcp-tool-policy.json and restarting. + // Deny", plus proactively setting one for a tool nothing has asked about yet - the gap + // AGENTS.md's governance section names as reachable only by hand-editing + // ~/.boss/mcp-tool-policy.json and restarting. Collected once here, like the other + // per-window state above, and shared with the trusted-plugins controls below rather than + // each subscribing to McpPolicyEngine.config on its own. val persistedPolicyConfig by McpToolRegistryImpl.policyEngine.config.collectAsState() - var showPolicyManager by remember { mutableStateOf(false) } - if (persistedPolicyConfig.rules.isNotEmpty()) { - androidx.compose.material.TextButton(onClick = { showPolicyManager = true }) { - Text( - "Persisted MCP policies (${persistedPolicyConfig.rules.size})", - color = BossTheme.colors.textSecondary, - ) - } - } - if (showPolicyManager) { - McpPolicyManagerDialog( - rules = persistedPolicyConfig.rules, - // Dispatchers.IO: revokePersistedPolicy does a synchronized atomicWriteText disk - // write, and setToolPolicy already made the equivalent invocation-path write take - // this same dispatcher (McpToolRegistryImpl) - this call site was the one still - // running it on the UI thread, where a click could block behind another write - // holding the same lock from a slow, networked or AV-scanned home directory. - onRevoke = { toolName -> - withContext(Dispatchers.IO) { - McpToolRegistryImpl.policyEngine.revokePersistedPolicy(toolName) - } - }, - onDismiss = { showPolicyManager = false }, - ) - } + McpPolicyManagerStatusItem(persistedPolicyConfig) // "Trust This Plugin" grants from the approval dialog - a persisted, provider-wide ALLOW, // listed and revoked individually (see the controls below) from the same config the @@ -358,6 +339,109 @@ fun BossRightBottomBar() { ) } +/** + * Inspect and revoke a persisted MCP tool policy, or set one proactively for a tool nothing has + * asked about yet. Split out of [BossRightBottomBar], whose own branching was already at + * detekt's [CyclomaticComplexMethod] ceiling before this grew a second dialog and a proactive + * candidate list. [persistedPolicyConfig] is collected by the caller, once, and shared with + * [McpProviderTrustControls] rather than each subscribing on its own. + */ +@Composable +private fun McpPolicyManagerStatusItem(persistedPolicyConfig: McpToolPolicyConfig) { + val allTools by McpToolRegistryImpl.allTools.collectAsState() + var showPolicyManager by remember { mutableStateOf(false) } + val ruleCount = persistedPolicyConfig.rules.size + if (ruleCount > 0 || allTools.isNotEmpty()) { + androidx.compose.material.TextButton(onClick = { showPolicyManager = true }) { + Text( + if (ruleCount > 0) "Persisted MCP policies ($ruleCount)" else "Set MCP tool policies", + color = BossTheme.colors.textSecondary, + ) + } + } + if (showPolicyManager) { + val disabledToolNames by McpToolRegistryImpl.disabledToolNames.collectAsState() + var candidateRefresh by remember { mutableStateOf(0) } + val availableTools = + remember(allTools, persistedPolicyConfig.rules, disabledToolNames, candidateRefresh) { + mcpProactivePolicyCandidates( + allTools, + persistedPolicyConfig.rules, + disabledToolNames, + McpToolRegistryImpl.policyEngine::revocationVersion, + ) + } + McpPolicyManagerDialog( + rules = persistedPolicyConfig.rules, + availableTools = availableTools, + // Dispatchers.IO: revokePersistedPolicy and setToolPolicyIfAbsent both do a + // synchronized atomicWriteText disk write - this call site was the one still running + // it on the UI thread, where a click could block behind another write holding the + // same lock from a slow, networked or AV-scanned home directory. + onRevoke = { toolName -> + withContext(Dispatchers.IO) { + McpToolRegistryImpl.policyEngine.revokePersistedPolicy(toolName) + } + }, + // setToolPolicyIfAbsent, not setToolPolicy: this path must add a rule only while the + // tool still has none of its own, atomically re-checked at write time - not just + // refuse a DENY, and not only when the revocation counter moved. An intervening + // explicit ASK or ALLOW, made through the reactive approval dialog for this same tool + // between "this row was offered" and this click reaching disk, never bumps that + // counter, so a `preserveDeny`-style guard alone would let this write silently + // clobber it (review on #636). tool.expectedRevocation is still passed, and still + // checked first, to catch a DENY or provider-wide reset the same way the reactive + // path's own capture-then-recheck does. + onSetPolicy = { tool, action -> + withContext(Dispatchers.IO) { + McpToolRegistryImpl.policyEngine.setToolPolicyIfAbsent( + tool.toolName, + action, + expectedRevocation = tool.expectedRevocation, + providerId = tool.providerId, + ) + } + }, + onRefreshCandidates = { candidateRefresh++ }, + onDismiss = { showPolicyManager = false }, + ) + } +} + +/** + * Every registered tool [McpPolicyManagerDialog] may write a proactive rule for: not disabled by + * the kill switch (a proactive rule for a disabled tool would do nothing - [McpToolRegistryImpl] + * resolves invocation against `tools`, which already excludes it), and not already in [rules] + * (that tool has its row in the saved-rules list instead). Sorted here, once, rather than by the + * composable on every recomposition - the caller already [remember]s the result. + * + * A pure function, not a `@Composable`, because the exclusion this expresses - never offer a rule + * for a tool that already has one, or that a rule could not affect - is the one thing standing + * between the dialog's Allow/Deny buttons and silently overwriting an existing DENY or granting a + * dead tool nothing can invoke. That is worth pinning on its own, not only through the dialog it + * feeds (review on #636). + * + * [revocationVersion] is injected - normally + * [ai.rever.boss.mcp.McpPolicyEngine.revocationVersion] - rather than reached for directly, so + * this stays a pure, testable mapping over its arguments; each candidate's own generation is + * stamped onto it as [McpToolIdentity.expectedRevocation], for [McpPolicyManagerDialog]'s write + * path to pass back to `setToolPolicyIfAbsent` unchanged. + */ +internal fun mcpProactivePolicyCandidates( + allTools: List, + rules: Map, + disabledToolNames: Set, + revocationVersion: (toolName: String, providerId: String?) -> Long, +): List = + allTools + .asSequence() + .filter { it.definition.name !in rules } + .filter { it.definition.name !in disabledToolNames } + .map { + McpToolIdentity(it.definition.name, it.providerId, revocationVersion(it.definition.name, it.providerId)) + }.sortedBy { it.toolName } + .toList() + /** * The "Trusted plugins" control for the provider-wide ALLOWs ("Trust This Plugin") granted * from the approval dialog. diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt index 8d0ad6b771..018c2537b6 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt @@ -1,6 +1,11 @@ package ai.rever.boss.components.dialogs +import ai.rever.boss.mcp.McpMutatingToolCatalog import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.mcp.McpProactivePolicyOutcome +import ai.rever.boss.mcp.sandbox.DefaultMcpRiskEvaluator +import ai.rever.boss.plugin.api.McpToolArgs +import ai.rever.boss.plugin.ui.BossColorScheme import ai.rever.boss.plugin.ui.BossDialog import ai.rever.boss.plugin.ui.BossTheme import androidx.compose.foundation.layout.Arrangement @@ -12,7 +17,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -29,8 +34,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties @@ -42,6 +50,21 @@ import kotlinx.coroutines.launch * revocation path this repo's own AGENTS.md names as reachable only by hand-editing * `~/.boss/mcp-tool-policy.json` and restarting, until this dialog existed. * + * It also lets an operator set a rule *proactively*, for a tool nothing has ever asked about yet. + * Before this, the only way a persistent rule came to exist was reactive: a plugin had to actually + * invoke the tool and trigger the approval dialog, and only then could "Always Allow"/"Always + * Deny" be clicked. An operator who already knows they never want a given tool run - `k8s_delete`, + * say - had no way to say so ahead of time; they could only wait to be asked, or hand-edit the + * policy file this dialog exists to make unnecessary. [availableTools] is every registered tool + * that does not already have a rule in [rules], and [onSetPolicy] writes one through + * [ai.rever.boss.mcp.McpPolicyEngine.setToolPolicyIfAbsent] - a dedicated add-only-if-absent + * write, not the reactive approval path's own [ai.rever.boss.mcp.McpPolicyEngine.setToolPolicy]. + * The two calls answer different questions: the reactive path replaces whatever rule a tool has + * when an operator explicitly clicks "Always Allow"/"Always Deny" *for that tool*, while this + * path must never replace a rule that already exists, however it got there - including one an + * explicit reactive decision wrote in the gap between this row being offered as a candidate and + * the operator's click here reaching disk (review on #636). + * * [rules] is a snapshot the caller re-derives from [ai.rever.boss.mcp.McpPolicyEngine.config] on * every recomposition, not a copy this dialog owns - a revoke calls back into [onRevoke] and * waits for the same state flow to reflect it, rather than mutating a local list that could drift @@ -51,9 +74,17 @@ import kotlinx.coroutines.launch @Suppress("LongMethod") // Declarative Compose layout. fun McpPolicyManagerDialog( rules: Map, + availableTools: List, onRevoke: suspend (toolName: String) -> Boolean, + onSetPolicy: suspend (tool: McpToolIdentity, action: McpPolicyAction) -> McpProactivePolicyOutcome, + onRefreshCandidates: () -> Unit, onDismiss: () -> Unit, ) { + val windowSize = LocalWindowInfo.current.containerSize + val windowHeight = with(LocalDensity.current) { windowSize.height.toDp() } + val windowWidth = with(LocalDensity.current) { windowSize.width.toDp() } + val maxHeight = if (windowHeight > 0.dp) (windowHeight - 32.dp).coerceAtLeast(1.dp) else 700.dp + val maxWidth = if (windowWidth > 0.dp) (windowWidth - 32.dp).coerceAtLeast(1.dp) else 480.dp val colors = BossTheme.colors val radii = BossTheme.radius val scope = rememberCoroutineScope() @@ -73,103 +104,126 @@ fun McpPolicyManagerDialog( properties = DialogProperties(), ) { Surface( - modifier = Modifier.width(480.dp).wrapContentHeight(), + modifier = Modifier.widthIn(max = maxWidth).width(480.dp).heightIn(max = maxHeight), shape = RoundedCornerShape(radii.dialog), color = colors.panel, ) { Column(modifier = Modifier.padding(24.dp)) { - Text( - text = "Persistent MCP Tool Policies", - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = colors.textPrimary, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = - "Saved from \"Always Allow\" / \"Always Deny\" in the tool approval dialog. " + - "Resetting a tool removes its saved rule - the next mutating call is " + - "governed by the default policy again, asking unless that default is " + - "itself Allow or Deny.", - fontSize = 12.sp, - color = colors.textSecondary, - ) - Spacer(modifier = Modifier.height(16.dp)) - - if (rules.isEmpty()) { + Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { Text( - text = "No saved rules. Default policies and session trust still apply.", - fontSize = 13.sp, + text = "Persistent MCP Tool Policies", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = colors.textPrimary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + "Saved from \"Always Allow\" / \"Always Deny\" in the tool approval dialog. " + + "Resetting a tool removes its saved rule - the next mutating call is " + + "governed by the default policy again, asking unless that default is " + + "itself Allow or Deny.", + fontSize = 12.sp, color = colors.textSecondary, ) - } else { + Spacer(modifier = Modifier.height(16.dp)) + + // The surrounding body scrolls as one region; Close stays outside it. Column( modifier = Modifier - .fillMaxWidth() - .heightIn(max = 320.dp) - .verticalScroll(rememberScrollState()), + .fillMaxWidth(), ) { - rules.toSortedMap().forEach { (toolName, action) -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = toolName, - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - color = colors.textPrimary, - ) - Text( - text = action.name, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, - color = if (action == McpPolicyAction.DENY) colors.alert else colors.signal, - ) - if (failedRevoke == toolName) { + if (rules.isEmpty()) { + Text( + text = "No saved rules. Default policies and session trust still apply.", + fontSize = 13.sp, + color = colors.textSecondary, + ) + } else { + rules.toSortedMap().forEach { (toolName, action) -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = toolName, + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + color = colors.textPrimary, + ) Text( - // Session trust clears even on failure, but a saved ALLOW still - // permits calls. Do not promise ASK while that durable rule remains. - text = - "Saved rule unchanged; this tool's session trust was cleared. " + - "See the host log.", + text = action.name, fontSize = 11.sp, - color = colors.alert, + fontWeight = FontWeight.Medium, + // Both values represent a durable, disk-persisted rule - + // the same category McpApprovalDialog's "Always Allow"/ + // "Always Deny" buttons are, which use warn/alert rather + // than an ordinary success color. + color = if (action == McpPolicyAction.DENY) colors.alert else colors.warn, ) + if (failedRevoke == toolName) { + Text( + // Session trust clears even on failure, but a saved ALLOW still + // permits calls. Do not promise ASK while that durable rule remains. + text = + "Saved rule unchanged; this tool's session trust was cleared. " + + "See the host log.", + fontSize = 11.sp, + color = colors.alert, + ) + } } - } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) - fun revoke() { - confirmingDeny = null - scope.launch { - failedRevoke = if (onRevoke(toolName)) null else toolName - } - } - // Removing an ALLOW is a de-escalation and needs no confirmation. - // Removing a DENY is the one control in this dialog that INCREASES - // what the tool is allowed to do, so it gets a second tap instead of - // firing on the first click like every other row's button does. - if (action == McpPolicyAction.DENY && confirmingDeny != toolName) { - TextButton(onClick = { confirmingDeny = toolName }) { - Text("Remove denial", fontSize = 12.sp, color = colors.alert) + fun revoke() { + confirmingDeny = null + scope.launch { + failedRevoke = if (onRevoke(toolName)) null else toolName + } } - } else if (action == McpPolicyAction.DENY) { - TextButton(onClick = { revoke() }) { - Text("Confirm remove?", fontSize = 12.sp, color = colors.alert) - } - } else { - TextButton(onClick = { revoke() }) { - Text("Reset", fontSize = 12.sp) + // Removing an ALLOW is a de-escalation and needs no confirmation. + // Removing a DENY is the one control in this dialog that INCREASES + // what the tool is allowed to do, so it gets a second tap instead of + // firing on the first click like every other row's button does. + if (action == McpPolicyAction.DENY && confirmingDeny != toolName) { + TextButton(onClick = { confirmingDeny = toolName }) { + Text("Remove denial", fontSize = 12.sp, color = colors.alert) + } + } else if (action == McpPolicyAction.DENY) { + TextButton(onClick = { revoke() }) { + Text("Confirm remove?", fontSize = 12.sp, color = colors.alert) + } + } else { + TextButton(onClick = { revoke() }) { + Text("Reset", fontSize = 12.sp) + } } } } } + + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Set a rule proactively", + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = colors.textPrimary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + "For a registered tool without a saved rule - no need to wait for an approval " + + "prompt to lock one down. A tool name, not a specific plugin: another " + + "plugin that later registers the same name inherits this rule too.", + fontSize = 11.sp, + color = colors.textSecondary, + ) + Spacer(modifier = Modifier.height(8.dp)) + ProactivePolicySectionContent(availableTools, onSetPolicy, onRefreshCandidates, colors) } } - Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Button( @@ -183,3 +237,179 @@ fun McpPolicyManagerDialog( } } } + +/** + * A tool the policy engine can name a rule for - just enough identity for this dialog. + * + * [expectedRevocation] is [ai.rever.boss.mcp.McpPolicyEngine.revocationVersion] for + * ([toolName], [providerId]) captured when this tool was offered as a proactive candidate - the + * same generation-token pattern the reactive approval path uses (`McpToolRegistryImpl`'s own + * `revocation` captured at invocation time), so a DENY or reset landing at either scope between + * "this row was offered" and the write actually happening is caught by + * [ai.rever.boss.mcp.McpPolicyEngine.setToolPolicyIfAbsent]'s own lock-protected recheck rather + * than silently overwritten. That recheck also refuses outright if [toolName] has picked up *any* + * rule since - not only a DENY, and not only through a revoke: an explicit ASK or ALLOW made + * through the reactive approval dialog in the meantime never touches [expectedRevocation], so the + * add-only-if-absent check is what actually protects it (review on #636). + */ +data class McpToolIdentity( + val toolName: String, + val providerId: String, + val expectedRevocation: Long, +) + +/** + * [availableTools] is already filtered and sorted by the caller + * ([ai.rever.boss.components.bars.horizontal.mcpProactivePolicyCandidates]); this renders it with + * no scroll or height bound of its own so it shares the one outer scroll region + * [McpPolicyManagerDialog] wraps both sections in. + */ +@Composable +private fun ProactivePolicySectionContent( + availableTools: List, + onSetPolicy: suspend (tool: McpToolIdentity, action: McpPolicyAction) -> McpProactivePolicyOutcome, + onRefreshCandidates: () -> Unit, + colors: BossColorScheme, +) { + val scope = rememberCoroutineScope() + // Like revoke feedback, retain only the latest attempted row outcome. + var failedSet by remember { mutableStateOf?>(null) } + // Allow raises standing privilege strictly further than anything else in this dialog can - + // ASK-forever into unattended ALLOW for every future agent and argument set - so it gets the + // same second-tap confirmation removing a DENY gets above, plus the risk/scope context + // McpApprovalDialog shows before its own "Always Allow" (review on #636). Deny only ever + // narrows what a tool can do, so it fires on the first tap like Reset does above. + var confirmingAllow by remember(availableTools) { mutableStateOf(null) } + + if (availableTools.isEmpty()) { + Text( + text = "Every registered tool already has a rule, or is disabled.", + fontSize = 12.sp, + color = colors.textSecondary, + ) + return + } + + Column(modifier = Modifier.fillMaxWidth()) { + availableTools.forEach { tool -> + ProactivePolicyRow( + tool = tool, + confirming = confirmingAllow == tool, + failureMessage = failedSet?.takeIf { it.first == tool.toolName }?.second, + onArmAllow = { confirmingAllow = tool }, + onSet = { action -> + confirmingAllow = null + scope.launch { + val outcome = onSetPolicy(tool, action) + failedSet = outcome.proactivePolicyMessage()?.let { tool.toolName to it } + if (outcome is McpProactivePolicyOutcome.Refused) onRefreshCandidates() + } + }, + colors = colors, + ) + } + } +} + +@Composable +private fun ProactivePolicyRow( + tool: McpToolIdentity, + confirming: Boolean, + failureMessage: String?, + onArmAllow: () -> Unit, + onSet: (McpPolicyAction) -> Unit, + colors: BossColorScheme, +) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = tool.toolName, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = tool.providerId, + fontSize = 10.sp, + color = colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (failureMessage != null) { + Text( + text = failureMessage, + fontSize = 10.sp, + color = colors.alert, + ) + } + } + + if (confirming) { + TextButton(onClick = { onSet(McpPolicyAction.ALLOW) }) { + Text("Confirm allow?", fontSize = 11.sp, color = colors.warn) + } + } else { + TextButton(onClick = onArmAllow) { + Text("Allow", fontSize = 11.sp, color = colors.warn) + } + } + TextButton(onClick = { onSet(McpPolicyAction.DENY) }) { + Text("Deny", fontSize = 11.sp, color = colors.alert) + } + } + if (confirming) { + ProactiveAllowConfirmation(tool.toolName, colors) + } + } +} + +/** The risk/scope context shown once Allow is armed, before the confirming second tap. */ +@Composable +private fun ProactiveAllowConfirmation( + toolName: String, + colors: BossColorScheme, +) { + val risk = remember(toolName) { DefaultMcpRiskEvaluator().evaluateRisk(toolName, McpToolArgs(emptyMap())) } + if (McpMutatingToolCatalog.isMutating(toolName)) { + Text("This tool performs mutations or external execution.", fontSize = 10.sp, color = colors.alert) + } + Text( + text = "${risk.level}: ${risk.reason}", + fontSize = 10.sp, + color = colors.alert, + ) + Text( + text = + "Applies to this tool name for all agents and arguments, across restarts " + + "and replacement plugins, until reset above.", + fontSize = 10.sp, + color = colors.textSecondary, + ) +} + +/** Refusals refresh candidates but always require a new operator confirmation. */ +internal fun McpProactivePolicyOutcome.proactivePolicyMessage(): String? = + when (this) { + McpProactivePolicyOutcome.Saved -> { + null + } + + McpProactivePolicyOutcome.Refused -> { + "Policy changed. Candidates refreshed; review the current policy before trying again." + } + + McpProactivePolicyOutcome.PolicyUnreadable -> { + "Policy file unreadable: all tools are withheld. Preserve a backup, repair the file, and restart BOSS." + } + + McpProactivePolicyOutcome.Denied -> { + "Current policy already denies this tool. Review the provider policy or defaults before adding a rule." + } + + is McpProactivePolicyOutcome.Failed -> { + "Could not save this rule. Check the host log and storage, then try again." + } + } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt index 2cf7355827..c5b0b2bbec 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt @@ -46,6 +46,25 @@ sealed interface McpPolicyFault { } } +/** + * Result of an atomic proactive write. Refusals need a refreshed candidate and a new + * operator decision; storage failures need storage recovery. Neither is a saved rule. + */ +sealed interface McpProactivePolicyOutcome { + data object Saved : McpProactivePolicyOutcome + + /** The candidate is stale, a rule exists, or an effective DENY/fault blocks the write. */ + data object Refused : McpProactivePolicyOutcome + + data object PolicyUnreadable : McpProactivePolicyOutcome + + data object Denied : McpProactivePolicyOutcome + + data class Failed( + val error: String, + ) : McpProactivePolicyOutcome +} + /** * Manages MCP tool execution policies (ALLOW / ASK / DENY). * @@ -211,19 +230,36 @@ class McpPolicyEngine( successMessage: String, failureMessage: String, faultFor: (key: String, error: String) -> McpPolicyFault, - ): Boolean { + ): Boolean = + writeConfig(key, logKey, updated, successMessage, failureMessage, faultFor) is McpProactivePolicyOutcome.Saved + + /** + * The persist-publish-log-fault sequence every durable write shares, returning + * [McpProactivePolicyOutcome] so [setToolPolicyIfAbsent] can report [McpProactivePolicyOutcome.Failed] + * distinctly from a guard refusal. [applyConfig] is the `Boolean`-returning adapter its four + * existing callers keep using unchanged. + */ + @Suppress("LongParameterList") // Mirrors applyConfig's own six fields; see its KDoc. + private fun writeConfig( + key: String, + logKey: String, + updated: McpToolPolicyConfig, + successMessage: String, + failureMessage: String, + faultFor: (key: String, error: String) -> McpPolicyFault, + ): McpProactivePolicyOutcome { val error = persistConfig(updated) return if (error != null) { val faultObj = faultFor(key, error) if (_fault.value !is McpPolicyFault.PersistedPolicyUnreadable) _fault.value = faultObj notifyFault(faultObj) logger.warn(LogCategory.SYSTEM, failureMessage, mapOf(logKey to key, "error" to error)) - false + McpProactivePolicyOutcome.Failed(error) } else { _config.value = updated _fault.value = null logger.info(LogCategory.SYSTEM, successMessage, mapOf(logKey to key)) - true + McpProactivePolicyOutcome.Saved } } @@ -253,6 +289,48 @@ class McpPolicyEngine( ) } + /** + * The proactive path's write: set a persistent rule for [toolName], but only while it still + * has none of its own. [setToolPolicy]'s [expectedRevocation] guard alone does not close + * this - only a *revoke* ([revokePersistedPolicy]/[revokeProviderPolicy]) bumps + * [revocationVersion], so an intervening explicit ASK or ALLOW made through the reactive + * approval dialog for this same tool never trips it, and a candidate offered while the tool + * had no rule could otherwise silently overwrite a decision made in between (review on + * #636). Checking `toolName !in rules` under the same [lock] the write itself takes closes + * that: any rule present at write time - ASK or ALLOW, not only DENY the way + * [setToolPolicy]'s `preserveDeny` protects - refuses the write, which is the "add only if + * absent" contract this call exists for. + * + * Returns [McpProactivePolicyOutcome] rather than [Boolean]: the caller needs to tell a + * refusal (refresh policy context before retrying) apart from a genuine disk + * failure (this operator's own choice did not take), which a bare `false` cannot express. + */ + fun setToolPolicyIfAbsent( + toolName: String, + action: McpPolicyAction, + expectedRevocation: Long, + providerId: String? = null, + ): McpProactivePolicyOutcome = + synchronized(lock) { + if (_fault.value is McpPolicyFault.PersistedPolicyUnreadable) { + return@synchronized McpProactivePolicyOutcome.PolicyUnreadable + } + if (revocationVersion(toolName, providerId) != expectedRevocation || toolName in _config.value.rules) { + return@synchronized McpProactivePolicyOutcome.Refused + } + if (policyFor(toolName, providerId) == McpPolicyAction.DENY) { + return@synchronized McpProactivePolicyOutcome.Denied + } + writeConfig( + key = toolName, + logKey = "tool", + updated = _config.value.copy(rules = _config.value.rules + (toolName to action)), + successMessage = "Updated tool policy: ${action.name}", + failureMessage = "Failed to persist MCP policy update", + faultFor = { k, e -> McpPolicyFault.PolicyPersistFailed(k, e) }, + ) + } + /** * Trust every tool [providerId] contributes, persistently - "Trust this plugin" in the * approval dialog, for an operator who does not want to approve each of its tools one at a diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/bars/horizontal/McpProactivePolicyCandidatesTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/bars/horizontal/McpProactivePolicyCandidatesTest.kt new file mode 100644 index 0000000000..5552a35627 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/bars/horizontal/McpProactivePolicyCandidatesTest.kt @@ -0,0 +1,114 @@ +package ai.rever.boss.components.bars.horizontal + +import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.plugin.api.McpToolDefinition +import ai.rever.boss.plugin.api.McpToolHandler +import ai.rever.boss.plugin.api.McpToolResult +import ai.rever.boss.plugin.api.RegisteredMcpTool +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * [mcpProactivePolicyCandidates] is the one thing standing between + * [ai.rever.boss.components.dialogs.McpPolicyManagerDialog]'s proactive Allow/Deny buttons and + * either overwriting an existing rule or offering a rule for a tool the kill switch has already + * withheld - both call sites this filter alone protects (review on #636). + * + * [McpToolIdentity.expectedRevocation] is the write-time guard the same review asked for: a + * candidate stamped with a stale generation refuses at [ai.rever.boss.mcp.McpPolicyEngine]'s own + * lock rather than silently overwriting a DENY or reset that landed after this candidate was + * offered. `NO_REVOCATION` is a stand-in for tests that don't care about the value itself. + */ +class McpProactivePolicyCandidatesTest { + @Test + fun `a tool with an existing rule is excluded`() { + val tools = listOf(tool("run_command"), tool("k8s_delete")) + val rules = mapOf("run_command" to McpPolicyAction.ALLOW) + + val result = mcpProactivePolicyCandidates(tools, rules, disabledToolNames = emptySet(), NO_REVOCATION) + + assertEquals(listOf("k8s_delete"), result.map { it.toolName }) + } + + @Test + fun `a kill-switch-disabled tool is excluded even with no rule`() { + val tools = listOf(tool("run_command"), tool("k8s_delete")) + + val result = + mcpProactivePolicyCandidates( + tools, + rules = emptyMap(), + disabledToolNames = setOf("k8s_delete"), + NO_REVOCATION, + ) + + assertEquals(listOf("run_command"), result.map { it.toolName }) + } + + @Test + fun `results are sorted by tool name`() { + val tools = listOf(tool("zzz_tool"), tool("aaa_tool"), tool("mmm_tool")) + + val result = + mcpProactivePolicyCandidates(tools, rules = emptyMap(), disabledToolNames = emptySet(), NO_REVOCATION) + + assertEquals(listOf("aaa_tool", "mmm_tool", "zzz_tool"), result.map { it.toolName }) + } + + @Test + fun `a tool that is both ruled and disabled is still excluded exactly once`() { + val tools = listOf(tool("run_command")) + val rules = mapOf("run_command" to McpPolicyAction.DENY) + + val result = + mcpProactivePolicyCandidates(tools, rules, disabledToolNames = setOf("run_command"), NO_REVOCATION) + + assertEquals(emptyList(), result) + } + + @Test + fun `every tool available offers its provider id alongside the name`() { + val tools = listOf(tool("run_command", providerId = "terminal-tab")) + + val result = + mcpProactivePolicyCandidates(tools, rules = emptyMap(), disabledToolNames = emptySet(), NO_REVOCATION) + + assertEquals("terminal-tab", result.single().providerId) + } + + @Test + fun `each candidate is stamped with its own tool-and-provider revocation generation`() { + val tools = listOf(tool("run_command", providerId = "terminal-tab"), tool("k8s_delete", providerId = "k8s-tab")) + val versions: Map, Long> = + mapOf(("run_command" to "terminal-tab") to 3L, ("k8s_delete" to "k8s-tab") to 7L) + + val result = + mcpProactivePolicyCandidates( + tools, + rules = emptyMap(), + disabledToolNames = emptySet(), + ) { toolName, providerId -> + versions.getValue(toolName to providerId) + } + + assertEquals(3L, result.single { it.toolName == "run_command" }.expectedRevocation) + assertEquals(7L, result.single { it.toolName == "k8s_delete" }.expectedRevocation) + } + + private fun tool( + name: String, + providerId: String = "test-provider", + ): RegisteredMcpTool = + RegisteredMcpTool( + providerId, + McpToolDefinition( + name = name, + description = "test", + handler = McpToolHandler { McpToolResult("ok") }, + ), + ) + + private companion object { + val NO_REVOCATION: (String, String?) -> Long = { _, _ -> 0L } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt new file mode 100644 index 0000000000..aad37f8f8f --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt @@ -0,0 +1,213 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.components.overlays.resetOverlayFieldForTest +import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.mcp.McpProactivePolicyOutcome +import ai.rever.boss.plugin.ui.BossBlueprintColorScheme +import ai.rever.boss.plugin.ui.BossBlueprintLightColorScheme +import ai.rever.boss.plugin.ui.BossOverlayHost +import ai.rever.boss.plugin.ui.LocalBossColors +import ai.rever.boss.plugin.ui.LocalHeavyweightOverlays +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.awt.image.BufferedImage +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class McpProactivePolicyDialogTest { + @get:Rule val rule = createComposeRule() + private val previousRenderer = BossOverlayHost.modalRenderer + private val previousHeavyweight = BossOverlayHost.useHeavyweightOverlays + + @Before fun setup() { + resetOverlayFieldForTest("modalRenderer") + resetOverlayFieldForTest("useHeavyweightOverlays") + BossOverlayHost.useHeavyweightOverlays = true + BossOverlayHost.modalRenderer = { _, _, content -> content() } + } + + @After fun cleanup() { + resetOverlayFieldForTest("modalRenderer") + resetOverlayFieldForTest("useHeavyweightOverlays") + BossOverlayHost.modalRenderer = previousRenderer + BossOverlayHost.useHeavyweightOverlays = previousHeavyweight + } + + private var captureTheme = "dark" + + private fun show( + light: Boolean = false, + windowSize: IntSize = IntSize(700, 360), + content: @Composable () -> Unit, + ) { + captureTheme = if (light) "light" else "dark" + rule.setContent { + CompositionLocalProvider( + LocalHeavyweightOverlays provides true, + LocalDensity provides Density(1f), + LocalBossColors provides if (light) BossBlueprintLightColorScheme else BossBlueprintColorScheme, + LocalWindowInfo provides + object : WindowInfo { + override val isWindowFocused = true + override val containerSize = windowSize + }, + ) { + val width = (if (windowSize.width > 0) windowSize.width else 700).dp + Box(Modifier.size(width, 360.dp).clipToBounds()) { content() } + } + } + rule.mainClock.advanceTimeBy(250) + } + + private var captureIndex = 0 + + private fun closeIsInsideWindow() { + if (System.getenv("BOSS_REVIEW_CAPTURE") == "1") captureLayout() + rule.onNodeWithText("Close").assertIsDisplayed() + val bounds = rule.onNodeWithText("Close").getUnclippedBoundsInRoot() + assertTrue(bounds.top >= 0.dp && bounds.bottom <= 360.dp, "Close bounds: $bounds") + } + + private fun captureLayout() { + val pixels = rule.onRoot().captureToImage().toPixelMap() + val image = BufferedImage(pixels.width, pixels.height, BufferedImage.TYPE_INT_ARGB) + for (y in 0 until pixels.height) for (x in 0 until pixels.width) image.setRGB(x, y, pixels[x, y].toArgb()) + val name = "${javaClass.simpleName}-$captureTheme-${++captureIndex}.png" + val output = File("build/reports/mcp-review", name) + output.parentFile.mkdirs() + javax.imageio.ImageIO.write(image, "png", output) + } + + @Test fun `short window keeps close visible with saved and proactive rows`() { + var closed = false + show { + McpPolicyManagerDialog( + rules = (1..30).associate { "saved-$it" to McpPolicyAction.DENY }, + availableTools = listOf(McpToolIdentity("tool", "provider".repeat(80), 0)), + onRevoke = { true }, + onSetPolicy = { _, _ -> McpProactivePolicyOutcome.Saved }, + onRefreshCandidates = {}, + onDismiss = { closed = true }, + ) + } + closeIsInsideWindow() + rule.onNodeWithText("Allow", substring = false).performScrollTo().assertIsDisplayed() + closeIsInsideWindow() + rule.onNodeWithText("Close").performClick() + rule.runOnIdle { assertTrue(closed) } + } + + @Test fun `replacing a candidate invalidates the armed confirmation in light theme`() { + val candidate = mutableStateOf(McpToolIdentity("tool", "provider", 0)) + var writes = 0 + show(light = true) { + McpPolicyManagerDialog( + emptyMap(), + listOf(candidate.value), + { true }, + { _, _ -> + writes++ + McpProactivePolicyOutcome.Saved + }, + {}, + {}, + ) + } + rule.onNodeWithText("Allow", substring = false).performScrollTo().performClick() + rule.onNodeWithText("Confirm allow?").assertExists() + rule.runOnIdle { candidate.value = candidate.value.copy(expectedRevocation = 1) } + rule.onNodeWithText("Confirm allow?").assertDoesNotExist() + rule.onNodeWithText("Allow", substring = false).performScrollTo().performClick() + rule.onNodeWithText("Confirm allow?").performScrollTo().performClick() + rule.runOnIdle { assertEquals(1, writes) } + } + + @Test fun `refused write refreshes candidates and reports refusal distinctly from disk failure`() { + var refreshes = 0 + show { + McpPolicyManagerDialog( + emptyMap(), + listOf(McpToolIdentity("tool", "provider", 0)), + { true }, + { _, _ -> McpProactivePolicyOutcome.Refused }, + { refreshes++ }, + {}, + ) + } + rule.onNodeWithText("Deny", substring = false).performScrollTo().performClick() + rule.onNodeWithText("Policy changed.", substring = true).assertExists() + rule.runOnIdle { assertEquals(1, refreshes) } + assertTrue(McpProactivePolicyOutcome.Failed("disk").proactivePolicyMessage()!!.contains("storage")) + } + + @Test fun `unreadable policy explains recovery inside the modal`() { + show { + McpPolicyManagerDialog( + emptyMap(), + listOf(McpToolIdentity("tool", "provider", 0)), + { true }, + { _, _ -> McpProactivePolicyOutcome.PolicyUnreadable }, + {}, + {}, + ) + } + rule.onNodeWithText("Deny", substring = false).performScrollTo().performClick() + rule.onNodeWithText("Policy file unreadable:", substring = true).performScrollTo().assertIsDisplayed() + assertTrue(McpProactivePolicyOutcome.Denied.proactivePolicyMessage()!!.contains("already denies")) + } + + @Test fun `unknown window size retains a usable dialog`() { + show(windowSize = IntSize.Zero) { + McpPolicyManagerDialog( + emptyMap(), + emptyList(), + { true }, + { _, _ -> McpProactivePolicyOutcome.Saved }, + {}, + {}, + ) + } + closeIsInsideWindow() + } + + @Test fun `narrow window keeps close horizontally inside the viewport`() { + show(windowSize = IntSize(360, 360)) { + McpPolicyManagerDialog( + emptyMap(), + emptyList(), + { true }, + { _, _ -> McpProactivePolicyOutcome.Saved }, + {}, + {}, + ) + } + closeIsInsideWindow() + assertTrue(rule.onNodeWithText("Close").getUnclippedBoundsInRoot().right <= 360.dp) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpPolicyEngineAddOnlyIfAbsentTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpPolicyEngineAddOnlyIfAbsentTest.kt new file mode 100644 index 0000000000..dd6da57a9f --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpPolicyEngineAddOnlyIfAbsentTest.kt @@ -0,0 +1,197 @@ +package ai.rever.boss.mcp + +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +/** + * [McpPolicyEngine.setToolPolicyIfAbsent] is the proactive path's add-only-if-absent guard + * (review on #636): a candidate offered by the bottom bar's policy manager is stamped with + * [McpPolicyEngine.revocationVersion] at offer time, but that counter alone does not protect an + * intervening explicit decision - only a revoke moves it, so an operator answering the reactive + * approval dialog's "Always Allow"/"Always Deny" for the same tool in between never trips it. + * These tests exercise exactly that race: capture a candidate, let an intervening explicit + * decision land, then confirm the queued proactive write is refused and the intervening decision + * - not the stale candidate - is what survives, including across a reload from disk. + */ +class McpPolicyEngineAddOnlyIfAbsentTest { + private val tempFiles = mutableListOf() + + private fun createTempPolicyFile(): File { + val dir = + kotlin.io.path + .createTempDirectory("mcp-policy-absent-test") + .toFile() + return File(dir, "mcp-tool-policy.json").also { tempFiles.add(it) } + } + + @AfterTest + fun cleanup() { + tempFiles.forEach { it.parentFile?.deleteRecursively() } + tempFiles.clear() + } + + @Test + fun `writes when the tool still has no rule, and it survives reload`() { + val file = createTempPolicyFile() + val engine = McpPolicyEngine(policyFile = file) + val generation = engine.revocationVersion("run_command") + + val outcome = engine.setToolPolicyIfAbsent("run_command", McpPolicyAction.ALLOW, generation) + + assertEquals(McpProactivePolicyOutcome.Saved, outcome) + assertEquals(McpPolicyAction.ALLOW, engine.policyFor("run_command")) + val reloaded = McpPolicyEngine(policyFile = file) + assertEquals(McpPolicyAction.ALLOW, reloaded.policyFor("run_command")) + } + + @Test + fun `an intervening explicit ASK is not overwritten by a stale candidate`() { + val file = createTempPolicyFile() + val engine = McpPolicyEngine(policyFile = file) + // Captured while "k8s_delete" had no rule - this is the candidate's generation. + val generation = engine.revocationVersion("k8s_delete") + + // The operator separately answers the reactive approval dialog for the very same tool. + // setToolPolicy neither bumps revocationVersion nor sees this proactive write coming. + engine.setToolPolicy("k8s_delete", McpPolicyAction.ASK) + + val outcome = engine.setToolPolicyIfAbsent("k8s_delete", McpPolicyAction.ALLOW, generation) + + assertEquals(McpProactivePolicyOutcome.Refused, outcome) + assertEquals(McpPolicyAction.ASK, engine.policyFor("k8s_delete")) + val reloaded = McpPolicyEngine(policyFile = file) + assertEquals(McpPolicyAction.ASK, reloaded.policyFor("k8s_delete")) + } + + @Test + fun `an intervening explicit ALLOW is not overwritten by a stale candidate`() { + val file = createTempPolicyFile() + val engine = McpPolicyEngine(policyFile = file) + val generation = engine.revocationVersion("helm_upgrade") + + engine.setToolPolicy("helm_upgrade", McpPolicyAction.ALLOW) + + val outcome = engine.setToolPolicyIfAbsent("helm_upgrade", McpPolicyAction.DENY, generation) + + assertEquals(McpProactivePolicyOutcome.Refused, outcome) + assertEquals(McpPolicyAction.ALLOW, engine.policyFor("helm_upgrade")) + val reloaded = McpPolicyEngine(policyFile = file) + assertEquals(McpPolicyAction.ALLOW, reloaded.policyFor("helm_upgrade")) + } + + @Test + fun `an intervening explicit DENY is not overwritten by a stale candidate`() { + val file = createTempPolicyFile() + val engine = McpPolicyEngine(policyFile = file) + val generation = engine.revocationVersion("docker_rm") + + engine.setToolPolicy("docker_rm", McpPolicyAction.DENY) + + val outcome = engine.setToolPolicyIfAbsent("docker_rm", McpPolicyAction.ALLOW, generation) + + assertEquals(McpProactivePolicyOutcome.Refused, outcome) + assertEquals(McpPolicyAction.DENY, engine.policyFor("docker_rm")) + } + + @Test + fun `a revoke between offer and write is still caught by the revocation check`() { + val file = createTempPolicyFile() + val engine = McpPolicyEngine(policyFile = file) + engine.setToolPolicy("git_status", McpPolicyAction.DENY) + // Candidate offered describing the CURRENT (post-DENY) generation, then the operator + // revokes that DENY before the proactive write reaches disk. + val generation = engine.revocationVersion("git_status") + engine.revokePersistedPolicy("git_status") + + val outcome = engine.setToolPolicyIfAbsent("git_status", McpPolicyAction.ALLOW, generation) + + assertEquals(McpProactivePolicyOutcome.Refused, outcome) + // The revoke, not the stale candidate, is what the tool resolves to: no rule of its own, + // so it falls to its default - ALLOW, since git_status is read-only, not the DENY the + // stale candidate never got to write, and not the ASK a mutating tool would fall back to. + assertEquals(McpPolicyAction.ALLOW, engine.policyFor("git_status")) + } + + @Test + fun `a genuine disk failure is distinguishable from a guard refusal`() { + val file = createTempPolicyFile() + val engine = McpPolicyEngine(policyFile = file) + val generation = engine.revocationVersion("run_command") + // atomicWriteText creates missing parent directories, so turning the policy file's own + // path into a directory is what makes the final atomic move fail - same trick + // `a failed revocation write is reported, not silently swallowed` uses. + file.mkdirs() + + val outcome = engine.setToolPolicyIfAbsent("run_command", McpPolicyAction.ALLOW, generation) + + assertIs(outcome) + } + + @Test + fun `a stale revocation is refused without touching the fault channel`() { + val file = createTempPolicyFile() + var reportedFault: McpPolicyFault? = null + val engine = McpPolicyEngine(policyFile = file, onFault = { reportedFault = it }) + val staleGeneration = engine.revocationVersion("run_command") + 1 + + val outcome = engine.setToolPolicyIfAbsent("run_command", McpPolicyAction.ALLOW, staleGeneration) + + assertEquals(McpProactivePolicyOutcome.Refused, outcome) + // A refusal is not a fault - nothing was actually attempted on disk. + assertNull(reportedFault) + } + + @Test + fun `provider deny refuses proactive writes without leaving a latent allow`() { + val file = createTempPolicyFile() + val engine = McpPolicyEngine(policyFile = file) + val generation = engine.revocationVersion("run_command", "provider") + engine.setProviderPolicy("provider", McpPolicyAction.DENY) + assertEquals( + McpProactivePolicyOutcome.Denied, + engine.setToolPolicyIfAbsent("run_command", McpPolicyAction.ALLOW, generation, "provider"), + ) + assertNull(engine.config.value.rules["run_command"]) + engine.revokeProviderPolicy("provider") + assertEquals(McpPolicyAction.ASK, McpPolicyEngine(policyFile = file).policyFor("run_command", "provider")) + } + + @Test + fun `damaged policy is never overwritten by a proactive allow or deny`() { + val file = createTempPolicyFile() + file.writeText("broken policy") + val engine = McpPolicyEngine(policyFile = file) + for (action in listOf(McpPolicyAction.ALLOW, McpPolicyAction.DENY)) { + assertEquals( + McpProactivePolicyOutcome.PolicyUnreadable, + engine.setToolPolicyIfAbsent("run_command", action, engine.revocationVersion("run_command")), + ) + assertEquals("broken policy", file.readText()) + assertIs(engine.fault.value) + } + } + + @Test + fun `fresh candidate can retry after provider reset without reusing stale approval`() { + val engine = McpPolicyEngine(policyFile = createTempPolicyFile()) + val stale = engine.revocationVersion("run_command", "provider") + engine.revokeProviderPolicy("provider") + assertEquals( + McpProactivePolicyOutcome.Refused, + engine.setToolPolicyIfAbsent("run_command", McpPolicyAction.ALLOW, stale, "provider"), + ) + assertEquals( + McpProactivePolicyOutcome.Saved, + engine.setToolPolicyIfAbsent( + "run_command", + McpPolicyAction.DENY, + engine.revocationVersion("run_command", "provider"), + "provider", + ), + ) + } +} From a206f69c90a892292c406c07e9bfb02ea587a081 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Tue, 15 Sep 2026 06:47:54 +0530 Subject: [PATCH 19/49] feat(mcp): add a clickable activity log for recent tool calls (#662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(search): index open tabs by their URL and file path (#606) (#648) Global Search matched an open tab only against its title, and TabResult hardcoded url = null / filePath = null on every hit. A browser tab whose title never mentions its domain, or an editor tab someone wants to find by path rather than filename, could not be found at all. searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and an editor tab's EditorTabInfo.filePath, through the same substring- containment gate (proseScore) searchRecentPages already uses for a page's URL, since a fuzzy subsequence match over a whole URL or path is close to meaningless. TabResult carries the real url/filePath instead of null, and the tab row's subtitle now leads with the workspace name (disambiguating identical URLs open in different workspaces, or across windows) followed by the url/filePath/tabType, in place of the old "tabType • workspaceName" line. 11 tests cover: URL-only and file-path-only matches, both fields populated for the right tab type and left null for the other/a blank path, a short query not matching a URL by fuzzy accident, title+URL both matching taking the higher (title) score with title highlight ranges preserved, live navigation changing which URL matches (and the old URL no longer matching), directory-path matching without a subsequence false-positive, and no-open-tabs contributing nothing rather than failing. * fix(browser): replace superseded capture requests (#509) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and buil… * fix(kernel): require verified caller identity for the active-tabs bridge (#646) Every ActiveTabsService RPC was reachable by any process able to open a connection to the kernel IPC server, not only the plugins the host itself loaded. watchActiveTabs disclosed every open tab's title and URL across every window with no credential at all, and createBrowserTab could force the browser to navigate to an arbitrary URL; selectTab/closeTab could manipulate what the user is looking at. All seven RPCs now require a verified caller identity, matching the pattern PR #505 established for the Secret Service: unary RPCs call authenticatedCallerOrRefuse, and the watchActiveTabs stream captures ProcessIdentityInterceptor.CURRENT_IDENTITY synchronously in the method body (before the flow is collected, on the calling thread the interceptor's context is current on) and re-checks it before every emission, so a revoked caller's stream is refused before an already-held handle could receive a value it should no longer see. Refusals are logged with a bind/revocation stage tag, and the three mutating RPCs (selectTab, createBrowserTab, closeTab) now record an attributed audit line - caller and RPC name only, no URLs or titles. Tested with a real gRPC server plus ProcessIdentityInterceptor: every RPC is refused with no credential and never reaches the provider, an authenticated caller can watch/read/select/create/close tabs, and a revoked caller's live watch stream terminates with PERMISSION_DENIED before receiving a value that arrived after revocation. * :arrow_up: deps:(deps): bump essenty from 2.5.0 to 2.6.0 (#655) Bumps `essenty` from 2.5.0 to 2.6.0. Updates `com.arkivanov.essenty:lifecycle` from 2.5.0 to 2.6.0 - [Release notes](https://github.com/arkivanov/Essenty/releases) - [Commits](https://github.com/arkivanov/Essenty/compare/2.5.0...2.6.0) Updates `com.arkivanov.essenty:state-keeper` from 2.5.0 to 2.6.0 - [Release notes](https://github.com/arkivanov/Essenty/releases) - [Commits](https://github.com/arkivanov/Essenty/compare/2.5.0...2.6.0) --- updated-dependencies: - dependency-name: com.arkivanov.essenty:lifecycle dependency-version: 2.6.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.arkivanov.essenty:state-keeper dependency-version: 2.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * :arrow_up: deps:(deps): bump org.graalvm.buildtools.native (#656) Bumps [org.graalvm.buildtools.native](https://github.com/graalvm/native-build-tools) from 1.1.11 to 1.1.12. - [Release notes](https://github.com/graalvm/native-build-tools/releases) - [Commits](https://github.com/graalvm/native-build-tools/compare/1.1.11...1.1.12) --- updated-dependencies: - dependency-name: org.graalvm.buildtools.native dependency-version: 1.1.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(mcp): let operators set MCP tool policy proactively Reactive approval was the only way a persistent tool policy came to exist: a plugin had to invoke the tool and trigger the approval dialog before "Always Allow"/"Always Deny" could be clicked. An operator who already knew they never wanted a tool run had no way to say so ahead of time, short of hand-editing ~/.boss/mcp-tool-policy.json. "Persisted MCP policies" (present even with zero saved rules, then labeled "Set MCP tool policies") now also offers a rule for any registered, not-yet-ruled, not-disabled tool - Allow requires a second confirming tap and shows the tool's risk assessment first, same as the approval dialog's own Always Allow. The write goes through a new McpPolicyEngine.setToolPolicyIfAbsent, not the reactive path's setToolPolicy: the proactive contract is "add only while this tool still has none of its own," and the existing expectedRevocation guard alone can't enforce that - revocationVersion only moves on a revoke, so an intervening explicit ASK or ALLOW made through the reactive approval dialog for the same tool never trips it, and a candidate captured while the tool had no rule could otherwise silently overwrite a decision made in between. setToolPolicyIfAbsent re-checks the rule map under the same lock the write itself takes, so any rule present at write time - not only a DENY - refuses the write, and returns a result distinguishing that refusal from a genuine disk failure rather than a bare boolean. mcpProactivePolicyCandidates filters the offered list to tools with no rule and not kill-switch-disabled, stamping each with its own revocation generation for the write to pass back. * fix(mcp): scope the activity log to registry-governed calls and fix classification Rebuilt standalone against dev (was stacked on #636's branch, which made this PR's real diff 11 files instead of the 4 it claimed). Replaced the setOf-based unsuccessful-call classification with an exhaustive when over McpApprovalDisposition: the enum has grown repeatedly across #336/#371/#636, and a setOf membership check silently counts a new disposition as a tool fault by falling through to the default. POLICY_PERSIST_FAILED gets its own "withheld" bucket instead of counting as a tool fault - the call never ran; a host disk fault stopped it, which is a different failure than the tool itself breaking. Made the classification and the disposition-name formatter internal and added a test suite pinning every disposition's bucket. The status item is now reachable even with no MCP activity yet, so an operator can open the log before the first call rather than only after one. Added hover cursor, a tooltip, and Role.Button semantics to the clickable status line, which previously looked like a bare label next to buttons that visibly were clickable. Resolved the ledger path through BossDirectories instead of hardcoding ~/.boss/mcp-calls.jsonl, which is wrong under BOSS_DATA_DIR. Softened the retention description to finite and best-effort (active file plus up to 5 rotated backups, write failures logged not retried) and scoped the "most recent calls" description to what actually reaches the ledger - an unregistered, unpermitted or kill-switch-disabled tool call never gets there. Bounded the provider-id text in each row so a long identifier can no longer push the policy/disposition chips off it. * fix(mcp): keep activity log close reachable in short windows * fix(mcp): preserve policy guards and refresh proactive decisions * fix(mcp): explain stable policy refusals and bound narrow dialogs * fix(mcp): honor host tooltip routing and ledger display bounds --------- Signed-off-by: dependabot[bot] Co-authored-by: Shikhar Goel Co-authored-by: Nilesh Vasita Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Shivang Co-authored-by: Shivanshu Co-authored-by: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Co-authored-by: Patel Karma Rohit Co-authored-by: Aditya8369 Co-authored-by: passionate-coder26 Co-authored-by: rehannayeem0786 Co-authored-by: rehannayeem0786 <128595058+rehannayeem0786@users.noreply.github.com> Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shwetas Dhake Co-authored-by: AdityaK-iiita Co-authored-by: Oleksii (Lexi) Kyrylchuk Co-authored-by: Sahvendra Co-authored-by: Aishwary Anand <201525941+Aishwary11-11@users.noreply.github.com> Co-authored-by: Aishwary Anand Co-authored-by: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Co-authored-by: ayush Co-authored-by: Anshul Pundhir <129892939+pundhiranshul@users.noreply.github.com> Co-authored-by: Sanjay Co-authored-by: arjun28115 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Achintya Chaurasia Co-authored-by: surfthewave-commits Co-authored-by: Ayushman <139611211+InfoSage05@users.noreply.github.com> Co-authored-by: Kanak Khandelwal <152528927+Kanakk23@users.noreply.github.com> Co-authored-by: Ishaan Chowdhury Co-authored-by: Karan Raj Co-authored-by: john_cybersage Co-authored-by: aryanbajaj868 <117334799+aryanbajaj868@users.noreply.github.com> Co-authored-by: Risa Labs Co-authored-by: john k Co-authored-by: kshivang Co-authored-by: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Co-authored-by: pundhiranshul Co-authored-by: Jaspreet Singh --- AGENTS.md | 31 ++ .../bars/horizontal/BossBottomBar.kt | 98 ++++- .../dialogs/McpActivityLogDialog.kt | 348 ++++++++++++++++++ .../ai/rever/boss/mcp/McpOperationLedger.kt | 3 + .../dialogs/McpActivityLogDialogTest.kt | 125 +++++++ .../dialogs/McpActivityLogLayoutTest.kt | 157 ++++++++ 6 files changed, 748 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpActivityLogDialog.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpActivityLogDialogTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpActivityLogLayoutTest.kt diff --git a/AGENTS.md b/AGENTS.md index 842c0c2cb1..fd978cf0f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2084,6 +2084,37 @@ HIGH/CRITICAL names use the mutating default, while unknown names remain allowed by default. Risk reasons and sanitized arguments appear together in the existing approval dialog. #362 is closed pending extraction into a management plugin. +**The bottom bar's "MCP: ``" status line is clickable into an activity log of the last 100 +calls this session.** Before this it was the only visibility into MCP activity at all - every +call before the current one, and the policy/approval decision behind it, was reachable only by +opening the rotated ledger file in a text editor. The dialog is a read-only view over +`McpOperationLedger.recentOperations`, scoped to calls that actually reached the policy engine - +`McpOperationLedger`'s own KDoc records that an unregistered, unpermitted or kill-switch-disabled +tool call is refused before that, so this is not a view over every MCP invocation attempt. +Retention is described as finite and best-effort (the active ledger file plus up to 5 rotated +backups, and a write failure there is logged rather than retried), not a guarantee older calls +are still on disk. Unsuccessful calls are broken down by `McpUnsuccessfulCategory` - denied, +cancelled, withheld (approval queue overflow or a host disk fault that stopped the call from running) or failed - through an exhaustive `when` over `McpApprovalDisposition` rather +than a `setOf`-based membership check, so a disposition the enum grows later is a compile error +here rather than silently counted as a tool fault. + +This is host UI for now; #416 is where activity/history UI and its ownership are meant to move +into a dynamic plugin. Should that move happen, the read surface it needs must be +**host-implemented and permission-gated** (an `mcp.activity.read`-shaped permission, the way MCP +tool calls already gate on `project.replace` and similar), never a member added to the ungated +`PluginContext.applicationEventBus`/`projectSearchProvider` surface this file documents elsewhere +- an ungated ledger read would hand any installed plugin every *other* plugin's tool names, +sanitized arguments and error snippets, and this file's own sanitizer caveat ("bounded and best +effort, not a guarantee for secrets under arbitrary keys") is acceptable for an operator-only host +dialog and not for a cross-plugin observation channel. Until that move happens, keeping this host +UI is also the stronger guarantee for a second reason: a governance viewer that can be disabled or +uninstalled by the plugins it governs is weaker than one that ships with the host. + +The idle activity entry appears only while MCP tools are exposed (existing history remains reachable). +The viewer uses the ledger instance's actual optional persistence path. Its tooltip follows the host +heavyweight overlay route. Width and height follow the originating window, with a fixed-cap fallback +while window metadata is not yet measured; Close stays outside the scrolling body. + **The "Persisted MCP policies" bottom bar button also lets an operator set a rule *proactively*, for a registered tool without a saved rule.** It is present even with zero saved rules (labeled "Set MCP tool policies" then). Allow requires a second confirming tap diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt index 7b1833135f..1f9fdc71b9 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt @@ -5,10 +5,13 @@ import ai.rever.boss.components.bars.getBarScrollbarConfig import ai.rever.boss.components.bars.horizontalScrollWithScrollbar import ai.rever.boss.components.bars.rememberBarContextMenuItems import ai.rever.boss.components.buttons.BossActionButton +import ai.rever.boss.components.dialogs.McpActivityLogDialog import ai.rever.boss.components.dialogs.McpPolicyManagerDialog import ai.rever.boss.components.dialogs.McpProviderTrustDialog import ai.rever.boss.components.dialogs.McpToolIdentity import ai.rever.boss.components.events.PanelEventBus +import ai.rever.boss.components.overlays.HoverTooltipBox +import ai.rever.boss.components.overlays.TooltipPlacement import ai.rever.boss.components.overlays.contextMenu import ai.rever.boss.components.plugin.registries.StatusBarRegistryImpl import ai.rever.boss.components.plugin.registries.owningPluginId @@ -30,6 +33,7 @@ import ai.rever.boss.utils.SystemUtils import ai.rever.boss.window.LocalWindowId import ai.rever.boss.window.LocalWindowProjectState import ai.rever.boss.window.Project +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.material.Divider @@ -48,6 +52,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -266,20 +276,11 @@ fun BossRightBottomBar() { ) } - // Governed Autonomy telemetry: show last executed tool, duration, and status - val recentOps by McpToolRegistryImpl.ledger.recentOperations.collectAsState() - recentOps.firstOrNull()?.let { lastOp -> - val statusSymbol = if (lastOp.isError) "✕" else "✓" - val statusColor = if (lastOp.isError) BossTheme.colors.alert else BossTheme.colors.textSecondary - Text( - text = "MCP: ${lastOp.toolName} (${lastOp.durationMs}ms) $statusSymbol", - color = statusColor, - fontSize = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 6.dp), - ) - } + // Governed Autonomy telemetry: show last executed tool, duration, and status. Clickable + // because this line used to be the ONLY visibility into MCP activity - every call before + // the current one, and the policy/approval decision behind it, was reachable only by + // opening the rotated MCP ledger file in a text editor. + McpActivityStatusItem() // Status message (temporary messages like "Space Saved") val statusMessage by StatusMessageManager.currentMessage.collectAsState() @@ -442,6 +443,75 @@ internal fun mcpProactivePolicyCandidates( }.sortedBy { it.toolName } .toList() +/** + * The single most recent MCP tool call, clickable into [McpActivityLogDialog] for everything + * behind it. Split out of [BossRightBottomBar] because that function's own branching was already + * at detekt's [CyclomaticComplexMethod] ceiling before this existed. + * + * Reachable even with no activity yet ([McpToolRegistryImpl.ledger]'s ring buffer empty): "has + * anything used MCP this session?" is a question worth being able to ask before the first call, + * not only after one - and is when an operator is most likely to be checking (review on #636). + */ +@Composable +private fun McpActivityStatusItem() { + val recentOps by McpToolRegistryImpl.ledger.recentOperations.collectAsState() + var showActivityLog by remember { mutableStateOf(false) } + val tools by McpToolRegistryImpl.tools.collectAsState() + if (recentOps.isEmpty() && tools.isEmpty() && !showActivityLog) return + val lastOp = recentOps.firstOrNull() + val statusText = + if (lastOp != null) { + "MCP: ${lastOp.toolName} (${lastOp.durationMs}ms) ${if (lastOp.isError) "✕" else "✓"}" + } else { + "MCP: no activity yet" + } + val statusColor = if (lastOp?.isError == true) BossTheme.colors.alert else BossTheme.colors.textSecondary + McpActivityStatusText( + text = statusText, + color = statusColor, + onClick = { showActivityLog = true }, + ) + if (showActivityLog) { + val totalCalls by McpToolRegistryImpl.ledger.totalCalls.collectAsState() + val totalErrors by McpToolRegistryImpl.ledger.totalErrors.collectAsState() + McpActivityLogDialog( + operations = recentOps, + totalCalls = totalCalls, + totalErrors = totalErrors, + ledgerPath = McpToolRegistryImpl.ledger.persistencePath, + onDismiss = { showActivityLog = false }, + ) + } +} + +/** + * The clickable status line's own affordance: a hand cursor on hover, a tooltip naming what the + * click does, and [Role.Button] semantics for assistive tech - a bare clickable [Text] next to + * [androidx.compose.material.TextButton]s that do look pressable had none of the three. + */ +@Composable +private fun McpActivityStatusText( + text: String, + color: Color, + onClick: () -> Unit, +) { + HoverTooltipBox(text = "Open the MCP activity log", placement = TooltipPlacement.TOP) { + Text( + text = text, + color = color, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClickLabel = "Open the MCP activity log", onClick = onClick) + .padding(horizontal = 6.dp, vertical = 2.dp) + .semantics { role = Role.Button }, + ) + } +} + /** * The "Trusted plugins" control for the provider-wide ALLOWs ("Trust This Plugin") granted * from the approval dialog. diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpActivityLogDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpActivityLogDialog.kt new file mode 100644 index 0000000000..3eeee7364c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpActivityLogDialog.kt @@ -0,0 +1,348 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.mcp.McpApprovalDisposition +import ai.rever.boss.mcp.McpOperationRecord +import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.plugin.ui.BossColorScheme +import ai.rever.boss.plugin.ui.BossDialog +import ai.rever.boss.plugin.ui.BossTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * The read side of [ai.rever.boss.mcp.McpOperationLedger] - until this dialog existed, the + * governed-autonomy audit trail BossConsole#336/#371 built had no viewer at all. `BossBottomBar` + * shows one line for the single most recent call and nothing behind it; the other ~99 in-memory + * records were reachable only by opening the rotated ledger file in a text editor. + * + * [operations] is [ai.rever.boss.mcp.McpOperationLedger.recentOperations]'s own in-memory ring + * buffer (newest first, capped at 100) - a live snapshot the caller re-collects, not a copy this + * dialog owns, so a call made while the dialog is open appears without reopening it. + * + * Scoped to what actually reaches the ledger: [ai.rever.boss.mcp.McpOperationLedger]'s own KDoc + * records that an unregistered, unpermitted or kill-switch-disabled tool call is refused before + * policy checks and never reaches [record] at all - this dialog is a view over registry-governed + * calls, not every attempt to invoke MCP. + */ +@Composable +@Suppress("LongMethod") // Declarative Compose layout. +fun McpActivityLogDialog( + operations: List, + totalCalls: Long, + totalErrors: Long, + ledgerPath: String? = null, + onDismiss: () -> Unit, +) { + val windowSize = LocalWindowInfo.current.containerSize + val windowHeight = with(LocalDensity.current) { windowSize.height.toDp() } + val windowWidth = with(LocalDensity.current) { windowSize.width.toDp() } + val maxHeight = if (windowHeight > 0.dp) (windowHeight - 32.dp).coerceAtLeast(1.dp) else 700.dp + val maxWidth = if (windowWidth > 0.dp) (windowWidth - 32.dp).coerceAtLeast(1.dp) else 560.dp + val colors = BossTheme.colors + val radii = BossTheme.radius + val timeFormat = remember { SimpleDateFormat("MMM d HH:mm:ss", Locale.getDefault()) } + BossDialog( + onDismissRequest = onDismiss, + properties = DialogProperties(), + ) { + Surface( + modifier = Modifier.widthIn(max = maxWidth).width(560.dp).heightIn(max = maxHeight), + shape = RoundedCornerShape(radii.dialog), + color = colors.panel, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { + Text( + text = "MCP Activity Log", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = colors.textPrimary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + "The most recent tool calls this session that reached the policy engine, " + + "up to the last 100 - which plugin, what was decided, and how it turned " + + "out. A call to an unregistered, unpermitted or kill-switch-disabled " + + "tool never reaches this list. Older entries roll off here." + + if (ledgerPath != null) { + " They may still be in $ledgerPath (active file plus up to 5 backups), " + + "on a best-effort basis. Write failures are logged, not retried." + } else { + " Disk persistence is not configured for this ledger." + }, + fontSize = 12.sp, + color = colors.textSecondary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + buildString { + append("$totalCalls call") + if (totalCalls != 1L) append("s") + append(" total") + // "Unsuccessful," not "errors": totalErrors also counts an operator's + // own Deny and a cancelled approval, which are governance working as + // designed, not tool faults - see the per-call breakdown below for + // which kind actually happened (review on #636). + if (totalErrors > 0) { + append(", $totalErrors unsuccessful") + } + }, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = colors.textSecondary, + ) + val unsuccessfulBreakdown = remember(operations) { operations.unsuccessfulBreakdown() } + if (unsuccessfulBreakdown.isNotEmpty()) { + Text( + text = "In the list below: " + unsuccessfulBreakdown.joinToString(", "), + fontSize = 11.sp, + color = colors.textSecondary, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + if (operations.isEmpty()) { + Text( + text = "No MCP tool calls recorded yet this session.", + fontSize = 13.sp, + color = colors.textSecondary, + ) + } else { + Column( + modifier = + Modifier + .fillMaxWidth(), + ) { + operations.forEach { op -> + McpOperationRow(op, timeFormat, colors) + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors(backgroundColor = colors.signal), + ) { + Text("Close", color = colors.onSignal, fontSize = 12.sp) + } + } + } + } + } +} + +@Composable +private fun McpOperationRow( + op: McpOperationRecord, + timeFormat: SimpleDateFormat, + colors: BossColorScheme, +) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + McpOperationHeaderRow(op, timeFormat, colors) + McpOperationMetaRow(op, colors) + op.errorSnippet?.let { snippet -> + Text( + text = snippet, + fontSize = 11.sp, + color = colors.alert, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun McpOperationHeaderRow( + op: McpOperationRecord, + timeFormat: SimpleDateFormat, + colors: BossColorScheme, +) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = timeFormat.format(Date(op.timestamp)), + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = colors.textSecondary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = op.toolName, + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = "${op.durationMs}ms", + fontSize = 11.sp, + color = colors.textSecondary, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = if (op.isError) "✕" else "✓", + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + // signalText, not signal: this is text, and BossDesignSystem's own rule is "signal on + // a rect, signalText on a Text/Icon" - signal falls under the 4.5:1 text floor on the + // light theme (review on #636). + color = if (op.isError) colors.alert else colors.signalText, + ) + } +} + +@Composable +private fun McpOperationMetaRow( + op: McpOperationRecord, + colors: BossColorScheme, +) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = op.providerId, + fontSize = 11.sp, + color = colors.textSecondary, + // Unbounded before this let a long provider id push the policy/disposition chips - + // the two fields this view exists to show - off the row entirely (review on #636/#662). + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 160.dp), + ) + Text( + text = op.policyApplied.name, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + color = if (op.policyApplied == McpPolicyAction.DENY) colors.alert else colors.textSecondary, + ) + Text( + text = op.approvalDisposition.readable(), + fontSize = 10.sp, + color = colors.textSecondary, + ) + } +} + +/** + * "SESSION_TRUSTED" -> "Session trusted" - the raw enum name is a log token, not UI copy. + * [Locale.ROOT], not the default: this is an enum constant, not user text, and under a locale + * like `tr-TR` a default-locale lowercase turns "CANCELLED_IN_FLIGHT" into "Cancelled ıin flight". + * + * `internal`, not `private`: the one thing standing between a new [McpApprovalDisposition] value + * silently mis-lowercasing and a compile error checking that is worth pinning on its own + * (review on #662). + */ +internal fun McpApprovalDisposition.readable(): String = + name.lowercase(Locale.ROOT).split("_").joinToString(" ") { it.replaceFirstChar(Char::uppercase) } + +/** + * How an unsuccessful call is summarized - split from a flat "N errors"/"N failed" count because + * [McpOperationRecord.isError] covers governance working as designed (an operator's own Deny, a + * cancelled approval) as well as an actual tool or host fault, and conflating them reads a + * governance success as a failure (review on #636). + */ +internal enum class McpUnsuccessfulCategory( + val label: String, +) { + DENIED("denied"), + CANCELLED("cancelled"), + + /** The tool never ran because the host could not obtain or persist approval. */ + WITHHELD("withheld"), + + /** Everything else: the tool (or the host executing it) genuinely failed. */ + FAILED("failed"), +} + +/** + * `internal`, not `private`, and an exhaustive `when` rather than the `setOf` membership checks + * this replaces: [McpApprovalDisposition] has grown repeatedly (#336/#371/#636 each added + * entries), and a `setOf`-based classification silently counts a new one as a tool fault by + * falling through to the default. A `when` with no `else` is a compile error the day the enum + * grows again, which is the point (review on #662). + * + * [McpApprovalDisposition.POLICY_PERSIST_FAILED] gets its own [McpUnsuccessfulCategory.WITHHELD] + * bucket rather than folding into denied or failed: `McpToolRegistryImpl` documents it as "the + * durable approval failed to save, and the call is withheld" - the tool itself never ran, so + * counting it as a tool fault misattributes a host disk fault (review on #636/#662). + * [McpApprovalDisposition.PROVIDER_TRUST_PERSIST_FAILED] is deliberately NOT in that bucket: its + * own KDoc says the call in hand still executes, so on the rare occasion it does carry + * `isError = true` that is the executed tool genuinely failing, not a withheld call. + */ +internal val McpApprovalDisposition.unsuccessfulCategory: McpUnsuccessfulCategory + get() = + when (this) { + McpApprovalDisposition.DENIED_BY_OPERATOR, + McpApprovalDisposition.POLICY_DENIED, + McpApprovalDisposition.PERSISTENTLY_DENIED, + -> McpUnsuccessfulCategory.DENIED + + McpApprovalDisposition.CANCELLED, + McpApprovalDisposition.CANCELLED_AWAITING_APPROVAL, + McpApprovalDisposition.CANCELLED_IN_FLIGHT, + McpApprovalDisposition.TIMEOUT, + -> McpUnsuccessfulCategory.CANCELLED + + McpApprovalDisposition.QUEUE_FULL, + McpApprovalDisposition.POLICY_PERSIST_FAILED, + -> McpUnsuccessfulCategory.WITHHELD + + McpApprovalDisposition.AUTO_ALLOWED, + McpApprovalDisposition.APPROVED_ONCE, + McpApprovalDisposition.SESSION_TRUSTED, + McpApprovalDisposition.PERSISTENTLY_ALLOWED, + McpApprovalDisposition.PROVIDER_TRUSTED, + McpApprovalDisposition.PROVIDER_TRUST_PERSIST_FAILED, + -> McpUnsuccessfulCategory.FAILED + } + +/** + * A short "3 denied, 1 cancelled" summary of the unsuccessful entries actually visible in this + * window, grouped by [McpUnsuccessfulCategory] and rendered in a fixed, stable order rather than + * map iteration order. + */ +internal fun List.unsuccessfulBreakdown(): List { + val counts = + filter { it.isError } + .groupingBy { it.approvalDisposition.unsuccessfulCategory } + .eachCount() + return McpUnsuccessfulCategory.entries.mapNotNull { category -> + counts[category]?.let { count -> "$count ${category.label}" } + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpOperationLedger.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpOperationLedger.kt index 403eda84e2..35ede6fdd3 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpOperationLedger.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpOperationLedger.kt @@ -29,6 +29,9 @@ class McpOperationLedger( private val maxBackupIndex: Int = 5, private val ringBufferCapacity: Int = 100, ) { + /** The actual optional persistence destination, for operator-facing inspection. */ + val persistencePath: String? get() = ledgerFile?.absolutePath + private val logger = BossLogger.forComponent("McpOperationLedger") private val writeLock = Any() private val json = Json { ignoreUnknownKeys = true } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpActivityLogDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpActivityLogDialogTest.kt new file mode 100644 index 0000000000..5bb13ffd82 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpActivityLogDialogTest.kt @@ -0,0 +1,125 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.mcp.McpApprovalDisposition +import ai.rever.boss.mcp.McpOperationRecord +import ai.rever.boss.mcp.McpPolicyAction +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * [McpApprovalDisposition.unsuccessfulCategory] is the one thing standing between the activity + * log's "N unsuccessful" summary and mislabeling a governance decision (an operator's own Deny) + * or a host disk fault (a persisted approval that failed to save) as a tool fault - both of which + * shipped as bugs before this classification existed (review on #636/#662). + */ +class McpActivityLogDialogTest { + @Test + fun `queue overflow withholds a call without an operator cancellation`() { + assertEquals(McpUnsuccessfulCategory.WITHHELD, McpApprovalDisposition.QUEUE_FULL.unsuccessfulCategory) + } + + @Test + fun `every denial disposition classifies as denied`() { + listOf( + McpApprovalDisposition.DENIED_BY_OPERATOR, + McpApprovalDisposition.POLICY_DENIED, + McpApprovalDisposition.PERSISTENTLY_DENIED, + ).forEach { disposition -> + assertEquals(McpUnsuccessfulCategory.DENIED, disposition.unsuccessfulCategory, disposition.name) + } + } + + @Test + fun `every cancellation-shaped disposition classifies as cancelled`() { + listOf( + McpApprovalDisposition.CANCELLED, + McpApprovalDisposition.CANCELLED_AWAITING_APPROVAL, + McpApprovalDisposition.CANCELLED_IN_FLIGHT, + McpApprovalDisposition.TIMEOUT, + ).forEach { disposition -> + assertEquals(McpUnsuccessfulCategory.CANCELLED, disposition.unsuccessfulCategory, disposition.name) + } + } + + @Test + fun `a failed persisted-approval write is withheld, not a tool fault`() { + // The tool never ran - McpToolRegistryImpl's denial branch returns isError = true for + // this disposition without ever reaching execution. Counting it as "failed" attributes a + // host disk fault to the tool. + assertEquals( + McpUnsuccessfulCategory.WITHHELD, + McpApprovalDisposition.POLICY_PERSIST_FAILED.unsuccessfulCategory, + ) + } + + @Test + fun `a call that ran and then failed classifies as a true tool fault`() { + listOf( + McpApprovalDisposition.AUTO_ALLOWED, + McpApprovalDisposition.APPROVED_ONCE, + McpApprovalDisposition.SESSION_TRUSTED, + McpApprovalDisposition.PERSISTENTLY_ALLOWED, + McpApprovalDisposition.PROVIDER_TRUSTED, + // Unlike POLICY_PERSIST_FAILED, this one's own KDoc says the call in hand still + // executes - a rare isError = true here is the executed tool genuinely failing. + McpApprovalDisposition.PROVIDER_TRUST_PERSIST_FAILED, + ).forEach { disposition -> + assertEquals(McpUnsuccessfulCategory.FAILED, disposition.unsuccessfulCategory, disposition.name) + } + } + + @Test + fun `the breakdown only counts entries marked isError, in a fixed category order`() { + val operations = + listOf( + record(McpApprovalDisposition.DENIED_BY_OPERATOR, isError = true), + record(McpApprovalDisposition.POLICY_DENIED, isError = true), + record(McpApprovalDisposition.CANCELLED_IN_FLIGHT, isError = true), + record(McpApprovalDisposition.POLICY_PERSIST_FAILED, isError = true), + // A successful call recorded with a "denial-shaped" disposition never happens in + // practice, but the breakdown must still key off isError, not the disposition alone. + record(McpApprovalDisposition.AUTO_ALLOWED, isError = false), + ) + + assertEquals( + listOf("2 denied", "1 cancelled", "1 withheld"), + operations.unsuccessfulBreakdown(), + ) + } + + @Test + fun `an empty operation list has no breakdown`() { + assertEquals(emptyList(), emptyList().unsuccessfulBreakdown()) + } + + @Test + fun `readable lowercases with Locale ROOT so a Turkish default locale cannot mangle it`() { + val original = Locale.getDefault() + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")) + assertEquals( + "Cancelled In Flight", + McpApprovalDisposition.CANCELLED_IN_FLIGHT.readable(), + ) + } finally { + Locale.setDefault(original) + } + } + + private fun record( + disposition: McpApprovalDisposition, + isError: Boolean, + ): McpOperationRecord = + McpOperationRecord( + id = "id", + timestamp = 0L, + toolName = "tool", + providerId = "provider", + policyApplied = McpPolicyAction.ASK, + approvalDisposition = disposition, + durationMs = 1L, + isError = isError, + sanitizedArgs = emptyMap(), + ) +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpActivityLogLayoutTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpActivityLogLayoutTest.kt new file mode 100644 index 0000000000..238145f409 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpActivityLogLayoutTest.kt @@ -0,0 +1,157 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.components.overlays.resetOverlayFieldForTest +import ai.rever.boss.mcp.McpApprovalDisposition +import ai.rever.boss.mcp.McpOperationRecord +import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.plugin.ui.BossBlueprintColorScheme +import ai.rever.boss.plugin.ui.BossBlueprintLightColorScheme +import ai.rever.boss.plugin.ui.BossOverlayHost +import ai.rever.boss.plugin.ui.LocalBossColors +import ai.rever.boss.plugin.ui.LocalHeavyweightOverlays +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.awt.image.BufferedImage +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class McpActivityLogLayoutTest { + @get:Rule val rule = createComposeRule() + private val previousRenderer = BossOverlayHost.modalRenderer + private val previousHeavyweight = BossOverlayHost.useHeavyweightOverlays + + @Before fun setup() { + resetOverlayFieldForTest("modalRenderer") + resetOverlayFieldForTest("useHeavyweightOverlays") + BossOverlayHost.useHeavyweightOverlays = true + BossOverlayHost.modalRenderer = { _, _, content -> content() } + } + + @After fun cleanup() { + resetOverlayFieldForTest("modalRenderer") + resetOverlayFieldForTest("useHeavyweightOverlays") + BossOverlayHost.modalRenderer = previousRenderer + BossOverlayHost.useHeavyweightOverlays = previousHeavyweight + } + + private var captureTheme = "dark" + + private fun show( + light: Boolean = false, + windowSize: IntSize = IntSize(700, 360), + content: @Composable () -> Unit, + ) { + captureTheme = if (light) "light" else "dark" + rule.setContent { + CompositionLocalProvider( + LocalHeavyweightOverlays provides true, + LocalDensity provides Density(1f), + LocalBossColors provides if (light) BossBlueprintLightColorScheme else BossBlueprintColorScheme, + LocalWindowInfo provides + object : WindowInfo { + override val isWindowFocused = true + override val containerSize = windowSize + }, + ) { + val width = (if (windowSize.width > 0) windowSize.width else 700).dp + Box(Modifier.size(width, 360.dp).clipToBounds()) { content() } + } + } + rule.mainClock.advanceTimeBy(250) + } + + private var captureIndex = 0 + + private fun closeIsInsideWindow() { + if (System.getenv("BOSS_REVIEW_CAPTURE") == "1") captureLayout() + rule.onNodeWithText("Close").assertIsDisplayed() + val bounds = rule.onNodeWithText("Close").getUnclippedBoundsInRoot() + assertTrue(bounds.top >= 0.dp && bounds.bottom <= 360.dp, "Close bounds: $bounds") + } + + private fun record(id: String) = + McpOperationRecord( + id = id, + timestamp = 0, + toolName = id, + providerId = "provider".repeat(80), + policyApplied = McpPolicyAction.ASK, + approvalDisposition = McpApprovalDisposition.POLICY_PERSIST_FAILED, + durationMs = 1, + isError = true, + sanitizedArgs = emptyMap(), + ) + + private fun captureLayout() { + val pixels = rule.onRoot().captureToImage().toPixelMap() + val image = BufferedImage(pixels.width, pixels.height, BufferedImage.TYPE_INT_ARGB) + for (y in 0 until pixels.height) for (x in 0 until pixels.width) image.setRGB(x, y, pixels[x, y].toArgb()) + val name = "${javaClass.simpleName}-$captureTheme-${++captureIndex}.png" + val output = File("build/reports/mcp-review", name) + output.parentFile.mkdirs() + javax.imageio.ImageIO.write(image, "png", output) + } + + @Test fun `short dark window keeps close visible with 100 long metadata rows`() { + var closed = false + show { McpActivityLogDialog((1..100).map { record("tool-$it") }, 100, 100) { closed = true } } + closeIsInsideWindow() + rule.onNodeWithText("tool-100").performScrollTo().assertIsDisplayed() + closeIsInsideWindow() + rule.onNodeWithText("Close").performClick() + rule.runOnIdle { assertTrue(closed) } + } + + @Test fun `empty light dialog updates live without reopening and still closes`() { + val records = mutableStateOf(emptyList()) + show(light = true) { McpActivityLogDialog(records.value, records.value.size.toLong(), 0, onDismiss = {}) } + rule.onNodeWithText("No MCP tool calls recorded yet this session.").assertExists() + closeIsInsideWindow() + rule.runOnIdle { records.value = listOf(record("new-call")) } + rule.onNodeWithText("No MCP tool calls recorded yet this session.").assertDoesNotExist() + rule.onNodeWithText("new-call").performScrollTo().assertIsDisplayed() + rule.onNodeWithText("Policy Persist Failed").assertExists() + closeIsInsideWindow() + } + + @Test fun `unknown window size falls back without collapsing the dialog`() { + show(windowSize = IntSize.Zero) { McpActivityLogDialog(emptyList(), 0, 0, onDismiss = {}) } + closeIsInsideWindow() + rule.onNodeWithText("Disk persistence is not configured", substring = true).assertExists() + } + + @Test fun `narrow window keeps close horizontally inside the viewport`() { + show(windowSize = IntSize(360, 360)) { + McpActivityLogDialog(listOf(record("tool")), 1, 1, ledgerPath = "/actual/ledger.jsonl", onDismiss = {}) + } + closeIsInsideWindow() + assertTrue(rule.onNodeWithText("Close").getUnclippedBoundsInRoot().right <= 360.dp) + rule.onNodeWithText("/actual/ledger.jsonl", substring = true).assertExists() + } +} From 236937c2712a95003c0f12a53c2435972dc95db6 Mon Sep 17 00:00:00 2001 From: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:10:56 +0530 Subject: [PATCH 20/49] Fix/spotlight project session isolation (#593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(overlays): honor RTL and fractional placement (#477) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and buil… * fix(kernel): require verified caller identity for the Git bridge (#635) * fix(kernel): require verified caller identity for the Git bridge GitServiceBridge had no caller-identity check on any of its fourteen RPCs. Any process able to open a connection to the kernel IPC server - not only the plugins the host itself loaded - could read the open project's full commit history and working-tree status, and could discardChanges (destroying uncommitted work), checkout, cherryPick or revert against the project's real git repository with no credential at all. Gate every RPC behind authenticatedCallerOrRefuse(), the same PERMISSION_DENIED-on-no-identity pattern PluginUIServiceBridge established for BossConsole#53. The four watch streams (file status, commit log, is-repo, is-loading) capture identity synchronously before returning and re-check it on every emission, so a revoked token stops a live stream rather than only new calls - matching the shape PR #550's review settled on for this bridge's siblings. * style(tests): wrap chained builder calls in GitServiceBridgeTest Real CI's ktlintDesktopTestSourceSetCheck flagged chain-method-continuation violations (a local run had missed this) - wrap the affected builder chains onto their own lines, per ktlintFormat. No behavior change. * test(kernel): cover revoked callers and log stream refusals --------- Co-authored-by: Shivang * fix(kernel): require verified caller identity for the Log bridge (#645) * fix(kernel): require verified caller identity for the Log bridge LogServiceBridge had no caller-identity check on any of its nine RPCs. Any process able to open a connection to the kernel IPC server - not only the plugins the host itself loaded - could watchLogs to see the live stdout/stderr stream of the running BOSS process, or call exportLogs to pull its full history, with no credential at all. Unlike a bridge whose values are deliberately routed through LogSanitizer at the call site, nothing about this stream is scrubbed for IPC exposure - it is whatever every logger.info/warn/error call site across this large and growing codebase happened to write, which is not a guarantee that none of it is a stack trace, a file path, or something else nobody meant to expose to an arbitrary local process. Gate every RPC behind authenticatedCallerOrRefuse(), the same PERMISSION_DENIED-on-no-identity pattern PluginUIServiceBridge established for BossConsole#53. The four watch streams capture identity synchronously before returning and re-check it on every emission, matching the shape PR #550's review settled on for this bridge's siblings. * test(kernel): verify revocation and diagnose stream refusals --------- Co-authored-by: Shivang * fix(security): randomize the IV for encrypt_text/decrypt_text (#647) * fix(security): randomize the IV for encrypt_text/decrypt_text encrypt_text called the 3-arg extensions.encrypt(plaintext, key, 'aes'), which pgcrypto documents as using an all-zero IV, so encryption was a pure function of (plaintext, key) alone: two equal passwords produced byte-identical ciphertext. Because create_secret encrypts server-side and RLS lets a user read their own password_encrypted back, any signed-in user was an encryption oracle - grouping a dump by ciphertext names every set of users sharing a password, with no key required. The same defect applied to recovery_codes_encrypted and twofa_secret. encrypt_text now draws a fresh random 16-byte IV per call and stores 'v2:' || base64(iv || ciphertext); decrypt_text dispatches on that prefix and falls back to the original zero-IV path for anything not yet migrated, so this deploys without a flag day. A backfill converts existing rows in all three columns, guarded by NOT LIKE 'v2:%' so a partial or repeated run is a no-op. Deliberately scoped to this one defect (BossConsole#618 names three). Defect 2 (the encryption key truncated to 32 string characters instead of 32 bytes) is not touched here: ops/rotate_master_encryption_key.sql records that a deployed key may be hex or base64, and this migration cannot tell which is live in any given project's Vault - guessing wrong would make get_encryption_key()'s result unusable and break encryption for every row on deploy. Defect 3 (no MAC/AEAD) needs its own wire-format review. ops/rotate_master_encryption_key.sql itself will need a follow-up before it can run again, since it manipulates raw ciphertext bytes assuming the old no-IV, no-prefix layout - both gaps are documented in the migration's own header. This could not be run against a real Postgres/pgTAP instance in this environment (no supabase/docker CLI available here) - the added test was written and reviewed by hand, and hosted CI's Database (pgTAP) check is the actual verification gate. * fix(security): un-qualify substring(... from ...) so it parses CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): pg_catalog.substring(x from y) is a syntax error. substring's SQL-standard FROM/FOR trailing syntax is recognized by the parser only on the bare function name - schema-qualifying it makes PostgreSQL treat it as an ordinary call, where FROM is not a valid argument separator. 20260909000000_encrypt_totp.sql's own decrypt_text already calls substring unqualified for exactly this reason; this migration's three call sites (envelope/iv/body extraction in decrypt_text, and the twofa_secret backfill) now match that convention. Both functions' search_path already resolves the unqualified name to pg_catalog's substring, so nothing else about name resolution changes. * fix(security): make key rotation understand the v2 IV envelope CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): the pre-existing master_key_rotation_test.sql runs ops/rotate_master_encryption_key.sql against real fixture data, and that script's re-encrypt loop calls extensions.decrypt/encrypt directly on raw ciphertext bytes, assuming the old zero-IV, no-prefix layout. The 'v2:' prefix the previous commit's encrypt_text now writes is not valid base64, so decoding it raised - exactly the gap this migration's own header comment already named as a follow-up, just sooner than expected. Added a pg_temp helper, rotate_ciphertext, that re-encrypts a mapped column's value under the new key while preserving whichever envelope it already carries: a 'v2:' row is decrypted with decrypt_iv under the old key and re-encrypted with encrypt_iv under the new key AND a fresh IV (a function rather than an inline SQL expression, specifically so gen_random_bytes(16) is evaluated once and reused for both the encrypt_iv call and the stored prefix - two calls would produce two different values and corrupt the row). A legacy row - every column before this pair of migrations, and still qbo_token_state / google_token_state going forward, since those are untouched by the IV migration - stays zero-IV, matching what it already was. Rotation's contract stays "same shape, new key"; upgrading a row's shape is the encryption migration's job, not rotation's. Updated the IV migration's own header comment, which had flagged this as a still-open follow-up, to point at the fix now landing alongside it instead. * fix(security): preserve plaintext bytes during IV upgrade * fix(security): make IV backfill atomic and diagnose unreadable rows --------- Co-authored-by: Shivang * fix(search): isolate Spotlight state per project session Move Spotlight mutable state out of the global service, scope file indexes to dialog project sessions, and serialize indexing while preserving cancellation. * fix(search): refresh current query when dialog index completes * fix(search): retain Spotlight index per window project * fix(search): satisfy Spotlight static analysis * style(search): format Spotlight regression tests * test(search): reconcile Spotlight lint rules * fix: verify encryption backfill and address integration review * feat(search): index open tabs by their URL and file path (#606) (#648) Global Search matched an open tab only against its title, and TabResult hardcoded url = null / filePath = null on every hit. A browser tab whose title never mentions its domain, or an editor tab someone wants to find by path rather than filename, could not be found at all. searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and an editor tab's EditorTabInfo.filePath, through the same substring- containment gate (proseScore) searchRecentPages already uses for a page's URL, since a fuzzy subsequence match over a whole URL or path is close to meaningless. TabResult carries the real url/filePath instead of null, and the tab row's subtitle now leads with the workspace name (disambiguating identical URLs open in different workspaces, or across windows) followed by the url/filePath/tabType, in place of the old "tabType • workspaceName" line. 11 tests cover: URL-only and file-path-only matches, both fields populated for the right tab type and left null for the other/a blank path, a short query not matching a URL by fuzzy accident, title+URL both matching taking the higher (title) score with title highlight ranges preserved, live navigation changing which URL matches (and the old URL no longer matching), directory-path matching without a subsequence false-positive, and no-open-tabs contributing nothing rather than failing. * fix(browser): replace superseded capture requests (#509) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and buil… * fix(kernel): require verified caller identity for the active-tabs bridge (#646) Every ActiveTabsService RPC was reachable by any process able to open a connection to the kernel IPC server, not only the plugins the host itself loaded. watchActiveTabs disclosed every open tab's title and URL across every window with no credential at all, and createBrowserTab could force the browser to navigate to an arbitrary URL; selectTab/closeTab could manipulate what the user is looking at. All seven RPCs now require a verified caller identity, matching the pattern PR #505 established for the Secret Service: unary RPCs call authenticatedCallerOrRefuse, and the watchActiveTabs stream captures ProcessIdentityInterceptor.CURRENT_IDENTITY synchronously in the method body (before the flow is collected, on the calling thread the interceptor's context is current on) and re-checks it before every emission, so a revoked caller's stream is refused before an already-held handle could receive a value it should no longer see. Refusals are logged with a bind/revocation stage tag, and the three mutating RPCs (selectTab, createBrowserTab, closeTab) now record an attributed audit line - caller and RPC name only, no URLs or titles. Tested with a real gRPC server plus ProcessIdentityInterceptor: every RPC is refused with no credential and never reaches the provider, an authenticated caller can watch/read/select/create/close tabs, and a revoked caller's live watch stream terminates with PERMISSION_DENIED before receiving a value that arrived after revocation. * fix(search): keep Spotlight indexing with its window --------- Signed-off-by: dependabot[bot] Co-authored-by: Shikhar Goel Co-authored-by: Nilesh Vasita Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Shivang Co-authored-by: Shivanshu Co-authored-by: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Co-authored-by: Patel Karma Rohit Co-authored-by: Aditya8369 Co-authored-by: passionate-coder26 Co-authored-by: Antriksh1984 Co-authored-by: rehannayeem0786 Co-authored-by: rehannayeem0786 <128595058+rehannayeem0786@users.noreply.github.com> Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shwetas Dhake Co-authored-by: AdityaK-iiita Co-authored-by: Oleksii (Lexi) Kyrylchuk Co-authored-by: Sahvendra Co-authored-by: Aishwary Anand <201525941+Aishwary11-11@users.noreply.github.com> Co-authored-by: Aishwary Anand Co-authored-by: ayush Co-authored-by: Anshul Pundhir <129892939+pundhiranshul@users.noreply.github.com> Co-authored-by: Sanjay Co-authored-by: arjun28115 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Achintya Chaurasia Co-authored-by: surfthewave-commits Co-authored-by: Ayushman <139611211+InfoSage05@users.noreply.github.com> Co-authored-by: Kanak Khandelwal <152528927+Kanakk23@users.noreply.github.com> Co-authored-by: Ishaan Chowdhury Co-authored-by: Karan Raj Co-authored-by: john_cybersage Co-authored-by: aryanbajaj868 <117334799+aryanbajaj868@users.noreply.github.com> Co-authored-by: Risa Labs Co-authored-by: john k Co-authored-by: kshivang Co-authored-by: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Co-authored-by: pundhiranshul Co-authored-by: Jaspreet Singh --- composeApp/detekt-baseline.xml | 4 +- .../ai/rever/boss/app/BossAppDialogs.kt | 3 + .../kotlin/ai/rever/boss/app/BossAppState.kt | 7 + .../components/dialogs/GlobalSearchDialog.kt | 137 ++++++------ .../dialogs/SpotlightSearchEffect.kt | 35 ++++ .../ai/rever/boss/search/FileIndexer.kt | 89 ++++---- .../rever/boss/search/GlobalSearchService.kt | 181 +++++----------- .../boss/search/SpotlightFileIndexOwner.kt | 43 ++++ .../dialogs/McpSearchNavigationWiringTest.kt | 2 +- .../ai/rever/boss/search/FileIndexerTest.kt | 198 ++++++++++++++++++ .../boss/search/GlobalSearchIsolationTest.kt | 89 ++++++++ .../boss/search/GlobalSearchNewSourcesTest.kt | 29 +-- .../boss/search/GlobalSearchOrderingTest.kt | 30 +-- .../rever/boss/search/GlobalSearchTabsTest.kt | 8 +- .../boss/search/SearchSourceRegistrarTest.kt | 26 +-- .../search/SpotlightFileIndexOwnerTest.kt | 126 +++++++++++ .../boss/search/SpotlightSearchEffectTest.kt | 102 +++++++++ 17 files changed, 805 insertions(+), 304 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/SpotlightSearchEffect.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/search/SpotlightFileIndexOwner.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/search/FileIndexerTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchIsolationTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SpotlightFileIndexOwnerTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SpotlightSearchEffectTest.kt diff --git a/composeApp/detekt-baseline.xml b/composeApp/detekt-baseline.xml index 112782eacc..419dac4c7d 100644 --- a/composeApp/detekt-baseline.xml +++ b/composeApp/detekt-baseline.xml @@ -140,7 +140,7 @@ CyclomaticComplexMethod:EditorContentProviderImpl.kt$EditorContentProviderImpl$override fun detectLanguage(filePath: String): String CyclomaticComplexMethod:EmailAuthService.kt$EmailAuthService$suspend fun verifyMagicLinkToken( token: String, email: String? = null, type: String = "magiclink", ): Result<Boolean> CyclomaticComplexMethod:EmptyContentScreen.kt$@Composable private fun Card( icon: ImageVector, title: String, description: String, isSelected: Boolean, enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) - CyclomaticComplexMethod:FileIndexer.kt$FileIndexer$private fun scanDirectory( dir: File, rootCanonicalPath: String, rootPathLength: Int, files: MutableList<IndexedFile>, depth: Int = 0, ) + CyclomaticComplexMethod:FileIndexer.kt$FileIndexer$private suspend fun scanDirectory( dir: File, rootCanonicalPath: String, rootPathLength: Int, files: MutableList<IndexedFile>, depth: Int = 0, ) CyclomaticComplexMethod:FileNameSanitizer.kt$FileNameSanitizer$fun sanitize( fileName: String, replacement: Char = '_', ): String CyclomaticComplexMethod:Fluck.kt$@Composable fun BrowserErrorView( error: Throwable, url: String, retryCount: Int = 0, maxRetries: Int = 3, onRetry: (() -> Unit)? = null, onReset: (() -> Unit)? = null, onResetBrowser: (() -> Unit)? = null, onRetryEngine: (() -> Unit)? = null, ) CyclomaticComplexMethod:Fluck.kt$FluckTabComponent$@Composable override fun Content() @@ -974,7 +974,7 @@ NestedBlockDepth:DynamicPluginManager.kt$DynamicPluginManager$suspend fun installPlugin( jarPath: String, enabled: Boolean = true, ): Result<DynamicPluginInfo> NestedBlockDepth:DynamicPluginManager.kt$DynamicPluginManager$suspend fun loadPersistedPlugins(plugins: List<PersistedPluginEntry>): Map<String, Result<DynamicPluginInfo>> NestedBlockDepth:DynamicPluginManager.kt$DynamicPluginManager.Companion$private suspend fun hotSwapApiLayerOrchestration(pluginDir: java.io.File): Result<Int> - NestedBlockDepth:FileIndexer.kt$FileIndexer$private fun scanDirectory( dir: File, rootCanonicalPath: String, rootPathLength: Int, files: MutableList<IndexedFile>, depth: Int = 0, ) + NestedBlockDepth:FileIndexer.kt$FileIndexer$private suspend fun scanDirectory( dir: File, rootCanonicalPath: String, rootPathLength: Int, files: MutableList<IndexedFile>, depth: Int = 0, ) NestedBlockDepth:FluckEngine.kt$FluckEngine$private fun cleanupStaleLockFiles(profileDir: java.nio.file.Path): Boolean NestedBlockDepth:FluckEngine.kt$FluckEngine$private fun killStaleChromiumProcesses() NestedBlockDepth:FontManager.kt$FontManager$private fun discoverAndCategorizeFonts(): Map<String, List<String>> diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt index 09bbf9b685..e16b553ca6 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt @@ -109,6 +109,7 @@ internal fun BossAppDialogs(state: BossAppState) { val splitViewState = state.splitViewState val windowProjectState = state.windowProjectState val selectedProject by windowProjectState.selectedProject.collectAsState() + val spotlightFileIndexer = state.spotlightFileIndexes.indexerFor(selectedProject.path) // Keymap settings (used by ShortcutHelpDialog) val keymapSettings by KeymapSettingsManager.currentSettings.collectAsState() @@ -473,6 +474,8 @@ internal fun BossAppDialogs(state: BossAppState) { GlobalSearchDialog( projectPath = selectedProject.path, + fileIndexer = spotlightFileIndexer, + onIndexProject = { state.spotlightFileIndexes.ensureIndexed(selectedProject.path) }, workspaceManager = workspaceManager, windowId = windowId, onDismiss = { diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt index 7a6763c3f6..c24c49d5ec 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt @@ -24,6 +24,7 @@ import ai.rever.boss.components.workspaces.LayoutWorkspace import ai.rever.boss.components.workspaces.workspaceManager import ai.rever.boss.html.HtmlFileOpenRequest import ai.rever.boss.mcp.McpApprovalRequest +import ai.rever.boss.search.SpotlightFileIndexOwner import ai.rever.boss.services.FileHandlerService import ai.rever.boss.services.TerminalHandlerService import ai.rever.boss.services.URLHandlerService @@ -86,6 +87,12 @@ internal class BossAppState( var newTabDialogInitialType by mutableStateOf(null) var showGlobalSearchDialog by mutableStateOf(false) + /** + * Retains one project index while this window remains open. Dialog-local search state is kept + * in GlobalSearchDialog so a reopen cannot restore another session's query or selection. + */ + val spotlightFileIndexes = SpotlightFileIndexOwner(coroutineScope) + /** * The tools launcher's dialog. * diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/GlobalSearchDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/GlobalSearchDialog.kt index cdc79047ab..2032c1927d 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/GlobalSearchDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/GlobalSearchDialog.kt @@ -6,6 +6,7 @@ import ai.rever.boss.mcp.McpToolRegistryImpl import ai.rever.boss.plugin.ui.BossDialog import ai.rever.boss.plugin.ui.BossTheme import ai.rever.boss.plugin.ui.BossThemeController +import ai.rever.boss.search.FileIndexer import ai.rever.boss.search.GlobalSearchService import ai.rever.boss.search.MatchRange import ai.rever.boss.search.SearchCategory @@ -63,6 +64,19 @@ import kotlinx.coroutines.launch private val globalSearchLogger = BossLogger.forComponent("GlobalSearchDialog") +/** + * Mutable state that belongs to one Spotlight dialog instance. Keeping it beside the composable + * makes project/session ownership explicit without adding another application-wide registry. + */ +internal class SpotlightDialogState { + var query by mutableStateOf("") + var results by mutableStateOf>(emptyList()) + var activeCategory by mutableStateOf(SearchCategory.ALL) + var selectedIndex by mutableStateOf(0) + var isSearching by mutableStateOf(false) + var scrollToSelected by mutableStateOf(false) +} + // Theme colors — reactive getters into the BOSS design system tokens // (getters, not cached vals, so theme switches re-skin the dialog). private val SelectionAccent get() = BossThemeController.current.colors.signal // signal — selection / primary @@ -123,15 +137,20 @@ private val TrailingChipMaxWidth = 140.dp * UI inspired by macOS Spotlight and the BOSS Dashboard design. * * @param projectPath The project directory to search in + * @param fileIndexer The window-owned index for [projectPath], retained across dialog reopen + * @param onIndexProject Requests indexing in the owning window's scope * @param onDismiss Called when dialog should close * @param onFileSelect Called when a file is selected, with the file's absolute path * @param onTabSelect Called when an open tab is selected, with windowId, panelId, and tabId * @param onBookmarkSelect Called when a bookmark is selected, with the bookmark config * @param onRunConfigSelect Called when a run config is selected, with the config ID */ +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun GlobalSearchDialog( projectPath: String, + fileIndexer: FileIndexer, + onIndexProject: () -> Unit, workspaceManager: WorkspaceManager, windowId: String, onDismiss: () -> Unit, @@ -145,28 +164,25 @@ fun GlobalSearchDialog( onPageSelect: ((url: String) -> Unit)? = null, onMcpToolSelect: ((result: SearchResult.McpToolResult) -> Unit)? = null, ) { - var searchQuery by remember { mutableStateOf("") } - var selectedIndex by remember { mutableStateOf(0) } - // Track if selection was changed by keyboard (to enable scroll) vs hover (no scroll) - var scrollToSelected by remember { mutableStateOf(false) } - val allResults by GlobalSearchService.searchResults.collectAsState() - val activeCategory by GlobalSearchService.activeCategory.collectAsState() - val isIndexing by GlobalSearchService.isIndexing.collectAsState() - val isSearching by GlobalSearchService.isSearching.collectAsState() + // The conditional caller removes this dialog on close, so this state belongs to exactly one + // open session. The window owns the longer-lived file index passed above. + val dialogState = remember(projectPath) { SpotlightDialogState() } + val indexedFiles by fileIndexer.indexedFiles.collectAsState() + val isIndexing by fileIndexer.isIndexing.collectAsState() val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() val searchFieldFocusRequester = remember { FocusRequester() } // Get filtered results based on active category val filteredResults = - remember(allResults, activeCategory) { - GlobalSearchService.getFilteredResults() + remember(dialogState.results, dialogState.activeCategory) { + GlobalSearchService.getFilteredResults(dialogState.results, dialogState.activeCategory) } // Get result counts by category val resultCounts = - remember(allResults) { - GlobalSearchService.getResultCounts() + remember(dialogState.results) { + GlobalSearchService.getResultCounts(dialogState.results) } // Animation state for staggered appearance @@ -190,60 +206,41 @@ fun GlobalSearchDialog( return@LaunchedEffect } globalSearchLogger.debug(LogCategory.UI, "Indexing project for search", mapOf("path" to projectPath)) - GlobalSearchService.indexProject(projectPath) + onIndexProject() } - // Debounced search as user types - // 50ms debounce balances responsiveness with avoiding excessive searches while typing fast - LaunchedEffect(searchQuery) { - if (searchQuery.isBlank()) { - GlobalSearchService.clearResults() - return@LaunchedEffect - } - delay(50) - // This window, so the Tools rows come from the sidebar this dialog can actually open, and - // so a signpost is offered only when its panel is present here - see SearchSources. - GlobalSearchService.search(searchQuery, windowId) - } + SpotlightSearchEffect(dialogState, windowId, indexedFiles) // Auto-scroll to selected item (only when triggered by keyboard) - LaunchedEffect(selectedIndex, scrollToSelected) { - if (scrollToSelected && filteredResults.isNotEmpty()) { - val clampedIndex = selectedIndex.coerceIn(0, filteredResults.size - 1) + LaunchedEffect(dialogState.selectedIndex, dialogState.scrollToSelected) { + if (dialogState.scrollToSelected && filteredResults.isNotEmpty()) { + val clampedIndex = dialogState.selectedIndex.coerceIn(0, filteredResults.size - 1) coroutineScope.launch { // A RESULT index is not a LazyColumn item index when sections are shown: each // section contributes a header and a trailing spacer of its own, so the two drift // by two per section above the selection. Arrowing into a late category used to // scroll visibly short of the row it had selected. - val grouped = activeCategory == SearchCategory.ALL + val grouped = dialogState.activeCategory == SearchCategory.ALL listState.animateScrollToItem(listItemIndexFor(clampedIndex, filteredResults, showSections = grouped)) } - scrollToSelected = false + dialogState.scrollToSelected = false } } // Clamp selected index when results change LaunchedEffect(filteredResults.size) { if (filteredResults.isNotEmpty()) { - val clampedIndex = selectedIndex.coerceIn(0, filteredResults.size - 1) - if (clampedIndex != selectedIndex) { - selectedIndex = clampedIndex + val clampedIndex = dialogState.selectedIndex.coerceIn(0, filteredResults.size - 1) + if (clampedIndex != dialogState.selectedIndex) { + dialogState.selectedIndex = clampedIndex } } } // Reset selected index when category changes - LaunchedEffect(activeCategory) { - selectedIndex = 0 - scrollToSelected = true - } - - // Clear results when dialog closes - DisposableEffect(Unit) { - onDispose { - GlobalSearchService.clearResults() - GlobalSearchService.setActiveCategory(SearchCategory.ALL) - } + LaunchedEffect(dialogState.activeCategory) { + dialogState.selectedIndex = 0 + dialogState.scrollToSelected = true } // Handle result selection @@ -344,23 +341,27 @@ fun GlobalSearchDialog( Key.DirectionUp -> { if (filteredResults.isNotEmpty()) { - selectedIndex = (selectedIndex - 1).coerceAtLeast(0) - scrollToSelected = true + dialogState.selectedIndex = (dialogState.selectedIndex - 1).coerceAtLeast(0) + dialogState.scrollToSelected = true } true } Key.DirectionDown -> { if (filteredResults.isNotEmpty()) { - selectedIndex = (selectedIndex + 1).coerceAtMost(filteredResults.size - 1) - scrollToSelected = true + dialogState.selectedIndex = + (dialogState.selectedIndex + 1).coerceAtMost(filteredResults.size - 1) + dialogState.scrollToSelected = true } true } Key.Enter -> { - if (filteredResults.isNotEmpty() && selectedIndex < filteredResults.size) { - selectResult(filteredResults[selectedIndex]) + if ( + filteredResults.isNotEmpty() && + dialogState.selectedIndex < filteredResults.size + ) { + selectResult(filteredResults[dialogState.selectedIndex]) } true } @@ -372,15 +373,15 @@ fun GlobalSearchDialog( // "No Tools Found" with nothing on the chip row to say where // the user was. Four more categories made that four times // likelier, so it stopped being survivable. - val categories = visibleCategories(resultCounts, activeCategory) - val currentIndex = categories.indexOf(activeCategory) + val categories = visibleCategories(resultCounts, dialogState.activeCategory) + val currentIndex = categories.indexOf(dialogState.activeCategory) val nextIndex = if (event.isShiftPressed) { (currentIndex - 1 + categories.size) % categories.size } else { (currentIndex + 1) % categories.size } - GlobalSearchService.setActiveCategory(categories[nextIndex]) + dialogState.activeCategory = categories[nextIndex] true } @@ -408,7 +409,7 @@ fun GlobalSearchDialog( enter = fadeIn(tween(200)) + slideInVertically(tween(200)) { -it / 2 }, ) { SearchDialogHeader( - fileCount = GlobalSearchService.getIndexedFileCount(), + fileCount = indexedFiles.size, isIndexing = isIndexing, onClose = onDismiss, ) @@ -422,13 +423,13 @@ fun GlobalSearchDialog( enter = fadeIn(tween(200, delayMillis = 50)) + slideInVertically(tween(200, delayMillis = 50)) { -it / 2 }, ) { SearchInputField( - query = searchQuery, + query = dialogState.query, onQueryChange = { newQuery -> - searchQuery = newQuery - selectedIndex = 0 + dialogState.query = newQuery + dialogState.selectedIndex = 0 }, focusRequester = searchFieldFocusRequester, - isSearching = isSearching, + isSearching = dialogState.isSearching, ) } @@ -436,20 +437,20 @@ fun GlobalSearchDialog( // Category filter tabs (only show when there are results) AnimatedVisibility( - visible = showContent && searchQuery.isNotBlank() && allResults.isNotEmpty(), + visible = showContent && dialogState.query.isNotBlank() && dialogState.results.isNotEmpty(), enter = fadeIn(tween(200, delayMillis = 75)), ) { CategoryTabs( - activeCategory = activeCategory, + activeCategory = dialogState.activeCategory, resultCounts = resultCounts, onCategorySelect = { category -> - GlobalSearchService.setActiveCategory(category) - selectedIndex = 0 + dialogState.activeCategory = category + dialogState.selectedIndex = 0 }, ) } - if (searchQuery.isNotBlank() && allResults.isNotEmpty()) { + if (dialogState.query.isNotBlank() && dialogState.results.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) } @@ -465,7 +466,7 @@ fun GlobalSearchDialog( .weight(1f), ) { when { - searchQuery.isBlank() -> { + dialogState.query.isBlank() -> { EmptySearchState() } @@ -473,16 +474,16 @@ fun GlobalSearchDialog( IndexingState() } - filteredResults.isEmpty() && !isSearching -> { - NoResultsState(query = searchQuery, category = activeCategory) + filteredResults.isEmpty() && !dialogState.isSearching -> { + NoResultsState(query = dialogState.query, category = dialogState.activeCategory) } else -> { SearchResultsList( results = filteredResults, - selectedIndex = selectedIndex, + selectedIndex = dialogState.selectedIndex, listState = listState, - showSections = activeCategory == SearchCategory.ALL, + showSections = dialogState.activeCategory == SearchCategory.ALL, onResultClick = { result -> selectResult(result) }, ) } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/SpotlightSearchEffect.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/SpotlightSearchEffect.kt new file mode 100644 index 0000000000..964905f3f1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/SpotlightSearchEffect.kt @@ -0,0 +1,35 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.search.GlobalSearchService +import ai.rever.boss.search.IndexedFile +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import kotlinx.coroutines.delay + +/** Runs searches with the lifetime and file snapshot of the dialog that owns them. */ +@Composable +internal fun SpotlightSearchEffect( + dialogState: SpotlightDialogState, + windowId: String?, + indexedFiles: List, +) { + // Debounced search as user types + // 50ms debounce balances responsiveness with avoiding excessive searches while typing fast + // A fresh dialog starts with an empty index. Re-run the current query when its scan + // completes, and cancel work owned by a replaced session even if the query is unchanged. + LaunchedEffect(dialogState, dialogState.query, windowId, indexedFiles) { + if (dialogState.query.isBlank()) { + dialogState.results = emptyList() + return@LaunchedEffect + } + delay(50) + // This window, so the Tools rows come from the sidebar this dialog can actually open, and + // so a signpost is offered only when its panel is present here - see SearchSources. + dialogState.isSearching = true + try { + dialogState.results = GlobalSearchService.search(dialogState.query, windowId, indexedFiles) + } finally { + dialogState.isSearching = false + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/search/FileIndexer.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/search/FileIndexer.kt index eb50159eb5..2902d048cd 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/search/FileIndexer.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/search/FileIndexer.kt @@ -2,7 +2,10 @@ package ai.rever.boss.search import ai.rever.boss.utils.logging.BossLogger import ai.rever.boss.utils.logging.LogCategory +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -24,9 +27,12 @@ private val logger = BossLogger.forComponent("FileIndexer") * for fast searching. Excludes common non-essential directories like * build outputs, node_modules, and hidden files. * - * Thread-safe: Uses a mutex to prevent concurrent indexing operations. + * Thread-safe: Uses a mutex to serialize indexing operations. Requests are queued rather than + * dropped, so callers that share an indexer must avoid submitting unbounded work. */ -class FileIndexer { +class FileIndexer( + private val scan: (suspend (String) -> List)? = null, +) { /** Mutex to ensure only one indexing operation runs at a time. */ private val indexingMutex = Mutex() @@ -82,7 +88,7 @@ class FileIndexer { /** * Index all files in the given project path. * - * Thread-safe: Uses mutex to prevent concurrent indexing operations. + * Thread-safe: Serializes indexing requests with a mutex; queued requests are not dropped. * * @param projectPath The root directory to index * @param forceReindex If true, re-index even if already indexed @@ -91,47 +97,45 @@ class FileIndexer { projectPath: String, forceReindex: Boolean = false, ) { - // Try to acquire lock without blocking - if already indexing, skip - if (!indexingMutex.tryLock()) { - logger.debug(LogCategory.FILE, "Index already in progress, skipping") - return - } + indexingMutex.withLock { + try { + // Check if already indexed (inside lock to prevent race) + if (!forceReindex && _indexedPath.value == projectPath && _indexedFiles.value.isNotEmpty()) { + logger.debug(LogCategory.FILE, "Project already indexed", mapOf("path" to projectPath)) + return@withLock + } - try { - // Check if already indexed (inside lock to prevent race) - if (!forceReindex && _indexedPath.value == projectPath && _indexedFiles.value.isNotEmpty()) { - logger.debug(LogCategory.FILE, "Project already indexed", mapOf("path" to projectPath)) - return - } + _isIndexing.value = true - _isIndexing.value = true + logger.info(LogCategory.FILE, "Starting file indexing", mapOf("path" to projectPath)) + val startTime = System.currentTimeMillis() - logger.info(LogCategory.FILE, "Starting file indexing", mapOf("path" to projectPath)) - val startTime = System.currentTimeMillis() + val files = + scan?.invoke(projectPath) + ?: withContext(Dispatchers.IO) { + scanProjectFiles(projectPath) + } - val files = - withContext(Dispatchers.IO) { - scanProjectFiles(projectPath) - } + _indexedFiles.value = files + _indexedPath.value = projectPath - _indexedFiles.value = files - _indexedPath.value = projectPath - - val elapsed = System.currentTimeMillis() - startTime - logger.info( - LogCategory.FILE, - "File indexing complete", - mapOf( - "path" to projectPath, - "fileCount" to files.size, - "elapsedMs" to elapsed, - ), - ) - } catch (e: Exception) { - logger.error(LogCategory.FILE, "Error indexing project", error = e) - } finally { - _isIndexing.value = false - indexingMutex.unlock() + val elapsed = System.currentTimeMillis() - startTime + logger.info( + LogCategory.FILE, + "File indexing complete", + mapOf( + "path" to projectPath, + "fileCount" to files.size, + "elapsedMs" to elapsed, + ), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error(LogCategory.FILE, "Error indexing project", error = e) + } finally { + _isIndexing.value = false + } } } @@ -147,7 +151,8 @@ class FileIndexer { /** * Scan all files in the project directory recursively. */ - private fun scanProjectFiles(projectPath: String): List { + private suspend fun scanProjectFiles(projectPath: String): List { + currentCoroutineContext().ensureActive() val rootDir = File(projectPath) if (!rootDir.exists() || !rootDir.isDirectory) { logger.warn(LogCategory.FILE, "Invalid project path", mapOf("path" to projectPath)) @@ -170,19 +175,21 @@ class FileIndexer { * Security: Validates that all indexed files remain within the project root * to prevent path traversal via symlinks. */ - private fun scanDirectory( + private suspend fun scanDirectory( dir: File, rootCanonicalPath: String, rootPathLength: Int, files: MutableList, depth: Int = 0, ) { + currentCoroutineContext().ensureActive() // Limit depth to prevent extremely deep traversal if (depth > maxDepth) return val children = dir.listFiles() ?: return for (child in children) { + currentCoroutineContext().ensureActive() val name = child.name // Skip hidden files and directories diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/search/GlobalSearchService.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/search/GlobalSearchService.kt index d5e29854a4..296ee892eb 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/search/GlobalSearchService.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/search/GlobalSearchService.kt @@ -16,9 +16,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext private val logger = BossLogger.forComponent("GlobalSearchService") @@ -34,27 +31,6 @@ private val logger = BossLogger.forComponent("GlobalSearchService") * - Plugin-contributed search results */ object GlobalSearchService { - private val fileIndexer = FileIndexer() - - private val _searchResults = MutableStateFlow>(emptyList()) - val searchResults: StateFlow> = _searchResults.asStateFlow() - - private val _isSearching = MutableStateFlow(false) - val isSearching: StateFlow = _isSearching.asStateFlow() - - private val _activeCategory = MutableStateFlow(SearchCategory.ALL) - val activeCategory: StateFlow = _activeCategory.asStateFlow() - - /** - * The file indexer's isIndexing state. - */ - val isIndexing: StateFlow = fileIndexer.isIndexing - - /** - * The currently indexed project path. - */ - val indexedPath: StateFlow = fileIndexer.indexedPath - /** * Maximum number of results per category. * @@ -108,46 +84,14 @@ object GlobalSearchService { return FuzzyMatcher.match(query, field, fieldLower)?.score?.takeIf { it >= MIN_SCORE } } - /** - * Index a project directory for file searching. - * - * If switching to a different project, the old index is automatically cleared - * to prevent memory buildup from multiple indexed projects. - * - * @param projectPath The root directory to index - * @param forceReindex If true, re-index even if already indexed - */ - suspend fun indexProject( - projectPath: String, - forceReindex: Boolean = false, - ) { - // Clear old index when switching projects to prevent memory leak - val currentPath = fileIndexer.indexedPath.value - if (currentPath != null && currentPath != projectPath) { - logger.debug( - LogCategory.FILE, - "Clearing old index before switching projects", - mapOf("oldPath" to currentPath, "newPath" to projectPath), - ) - fileIndexer.clearIndex() - } - fileIndexer.indexProject(projectPath, forceReindex) - } - - /** - * Set the active search category for filtering. - */ - fun setActiveCategory(category: SearchCategory) { - _activeCategory.value = category - } - /** * Search across all data sources matching the given query. * * Searches are run in parallel across all categories for better performance * with large datasets. Also queries registered search providers from plugins. * - * @param query The search query + * @param rawQuery The search query + * @param indexedFiles The file snapshot owned by this dialog project session * @return every match from every source, in no particular order. [getFilteredResults] is what * orders them - by category, then score - and is the only order anything draws or arrows * through. @@ -155,6 +99,7 @@ object GlobalSearchService { suspend fun search( rawQuery: String, windowId: String?, + indexedFiles: List, ): List { // Trimmed once, here, so all nine sources agree. proseScore trimmed its own needle and the // fuzzy sources did not, which made trailing whitespace change the SHAPE of the results @@ -163,52 +108,44 @@ object GlobalSearchService { // the same asymmetry proseScore's trim was added to avoid, pointing the other way. val query = rawQuery.trim() if (query.isBlank()) { - _searchResults.value = emptyList() return emptyList() } - _isSearching.value = true - - try { - // Read ONCE, and shared by the two sources that need it. Both used to call - // SearchSources.tools(windowId) from their own async, which did the slot flatten twice - // per keystroke and, worse, let them see different snapshots: a plugin registering - // between the two reads yields a Tool row whose signpost was filtered out, or the - // reverse. searchSettings' KDoc promises the two agree, so they have to read the same - // list rather than two lists that usually match. - val windowTools = SearchSources.tools(windowId) - - val results = - withContext(Dispatchers.Default) { - // Run all searches in parallel for better performance - coroutineScope { - val searchResults = - listOf( - async { isolated("files") { searchFiles(query) } }, - async { isolated("tabs") { searchTabs(query) } }, - // Includes bookmarks from plugin - async { isolated("plugins") { searchPluginProviders(query) } }, - async { isolated("runConfigs") { searchRunConfigs(query) } }, - async { isolated("commands") { searchCommands(query) } }, - async { isolated("tools") { searchTools(query, windowTools) } }, - async { isolated("settings") { searchSettings(query, windowTools) } }, - async { isolated("mcp") { searchMcpTools(query) } }, - async { isolated("pages") { searchRecentPages(query) } }, - ).awaitAll().flatten() - - // Deliberately NOT sorted here. getFilteredResults is what orders results - // for every reader - category first, then score - and sorting again on the - // way in only invited the belief that this list is the drawn order. It is - // not; it is the unordered union of the sources. - searchResults - } + // Read ONCE, and shared by the two sources that need it. Both used to call + // SearchSources.tools(windowId) from their own async, which did the slot flatten twice + // per keystroke and, worse, let them see different snapshots: a plugin registering + // between the two reads yields a Tool row whose signpost was filtered out, or the + // reverse. searchSettings' KDoc promises the two agree, so they have to read the same + // list rather than two lists that usually match. + val windowTools = SearchSources.tools(windowId) + + val results = + withContext(Dispatchers.Default) { + // Run all searches in parallel for better performance + coroutineScope { + val searchResults = + listOf( + async { isolated("files") { searchFiles(query, indexedFiles) } }, + async { isolated("tabs") { searchTabs(query) } }, + // Includes bookmarks from plugin + async { isolated("plugins") { searchPluginProviders(query) } }, + async { isolated("runConfigs") { searchRunConfigs(query) } }, + async { isolated("commands") { searchCommands(query) } }, + async { isolated("tools") { searchTools(query, windowTools) } }, + async { isolated("settings") { searchSettings(query, windowTools) } }, + async { isolated("mcp") { searchMcpTools(query) } }, + async { isolated("pages") { searchRecentPages(query) } }, + ).awaitAll().flatten() + + // Deliberately NOT sorted here. getFilteredResults is what orders results + // for every reader - category first, then score - and sorting again on the + // way in only invited the belief that this list is the drawn order. It is + // not; it is the unordered union of the sources. + searchResults } + } - _searchResults.value = results - return results - } finally { - _isSearching.value = false - } + return results } /** @@ -262,7 +199,7 @@ object GlobalSearchService { /** * Get results filtered by the active category, in the order they are drawn. * - * **Category first, score second.** `_searchResults` is sorted by score alone, which is the + * **Category first, score second.** The source union is sorted by score alone, which is the * right order for one category and the wrong one for "All": the dialog draws "All" grouped * into sections, walking [SearchCategory] in declaration order, and it numbers rows as it goes. * The keyboard indexes into THIS list. While the two orders disagreed, the highlighted row and @@ -274,10 +211,10 @@ object GlobalSearchService { * * A no-op for a single category, where the ordinal is constant and score order survives. */ - fun getFilteredResults(): List { - val category = _activeCategory.value - val results = _searchResults.value - + fun getFilteredResults( + results: List, + category: SearchCategory, + ): List { val inCategory = if (category == SearchCategory.ALL) { results @@ -293,29 +230,29 @@ object GlobalSearchService { /** * Get result counts by category. */ - fun getResultCounts(): Map { - val results = _searchResults.value - return SearchCategory.entries.associateWith { category -> + fun getResultCounts(results: List): Map = + SearchCategory.entries.associateWith { category -> if (category == SearchCategory.ALL) { results.size } else { results.count { it.category == category } } } - } /** * Search files using fuzzy matching. */ - private fun searchFiles(query: String): List { - val files = fileIndexer.indexedFiles.value - if (files.isEmpty()) { + private fun searchFiles( + query: String, + indexedFiles: List, + ): List { + if (indexedFiles.isEmpty()) { return emptyList() } val results = mutableListOf() - for (file in files) { + for (file in indexedFiles) { val nameMatch = FuzzyMatcher.match(query, file.name, file.lowerName) if (nameMatch != null && nameMatch.score >= MIN_SCORE) { @@ -750,24 +687,4 @@ object GlobalSearchService { modifiers: List, key: String, ): String = formatShortcutLabel(modifiers, key) - - /** - * Clear search results. - */ - fun clearResults() { - _searchResults.value = emptyList() - } - - /** - * Clear the file index. - */ - fun clearIndex() { - fileIndexer.clearIndex() - _searchResults.value = emptyList() - } - - /** - * Get the count of indexed files. - */ - fun getIndexedFileCount(): Int = fileIndexer.getFileCount() } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/search/SpotlightFileIndexOwner.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/search/SpotlightFileIndexOwner.kt new file mode 100644 index 0000000000..9b2b441a97 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/search/SpotlightFileIndexOwner.kt @@ -0,0 +1,43 @@ +package ai.rever.boss.search + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Holds the one Spotlight file index retained by a window between dialog sessions. + * + * File contents are derived solely from a project path, unlike a dialog's query, results, category, + * and selection. Retaining one path-index pair keeps reopening Spotlight warm without retaining a growing + * collection of projects: the bound is one index per open window, including separate indexes for + * windows on the same project. Replacing the project cancels its scan and clears the old index. + * Access is confined to the window's composition thread. Scans belong to the window scope, so + * closing a dialog does not abandon a scan that a reopened dialog still needs. + */ +internal class SpotlightFileIndexOwner( + private val scope: CoroutineScope, + private val createIndexer: () -> FileIndexer = { FileIndexer() }, +) { + private var projectPath: String? = null + private var fileIndexer: FileIndexer? = null + private var indexingJob: Job? = null + + fun indexerFor(nextProjectPath: String): FileIndexer { + fileIndexer?.takeIf { projectPath == nextProjectPath }?.let { return it } + + indexingJob?.cancel() + indexingJob = null + fileIndexer?.clearIndex() + return createIndexer().also { + projectPath = nextProjectPath + fileIndexer = it + } + } + + fun ensureIndexed(projectPath: String) { + if (projectPath.isBlank()) return + val indexer = indexerFor(projectPath) + if (indexingJob?.isActive == true) return + indexingJob = scope.launch { indexer.indexProject(projectPath) } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpSearchNavigationWiringTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpSearchNavigationWiringTest.kt index b5b9d1d8b1..dd8122ee51 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpSearchNavigationWiringTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpSearchNavigationWiringTest.kt @@ -10,7 +10,7 @@ class McpSearchNavigationWiringTest { @Test fun `mouse and keyboard MCP selection reach the host navigation callback`() { val source = File("src/commonMain/kotlin/ai/rever/boss/components/dialogs/GlobalSearchDialog.kt").readText() - assertContains(source, "selectResult(filteredResults[selectedIndex])") + assertContains(source, "selectResult(filteredResults[dialogState.selectedIndex])") assertContains(source, "onResultClick = { result -> selectResult(result) }") val mcpBranch = source diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/FileIndexerTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/FileIndexerTest.kt new file mode 100644 index 0000000000..39a1c8029e --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/FileIndexerTest.kt @@ -0,0 +1,198 @@ +package ai.rever.boss.search + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class FileIndexerTest { + @Test + fun `a later request waits for the active scan and then publishes its own files`() = + runBlocking { + val firstScanStarted = CompletableDeferred() + val releaseFirstScan = CompletableDeferred() + val calls = mutableListOf() + val indexer = + FileIndexer { projectPath -> + calls += projectPath + if (projectPath == "project-a") { + firstScanStarted.complete(Unit) + releaseFirstScan.await() + } + listOf(indexedFile(projectPath)) + } + + val first = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-a") } + firstScanStarted.await() + val second = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-b") } + + assertFalse(second.isCompleted, "the second request must wait rather than be dropped") + assertEquals(listOf("project-a"), calls) + + releaseFirstScan.complete(Unit) + first.await() + second.await() + assertFalse(indexer.isIndexing.value, "completed or failed scans must release the indexing indicator") + + assertEquals(listOf("project-a", "project-b"), calls) + assertEquals("project-b", indexer.indexedPath.value) + assertEquals(listOf("project-b/file.kt"), indexer.indexedFiles.value.map { it.path }) + } + + @Test + fun `cancelling an active scan propagates and publishes no stale metadata`() = + runBlocking { + val scanStarted = CompletableDeferred() + val neverRelease = CompletableDeferred() + val indexer = + FileIndexer { + scanStarted.complete(Unit) + neverRelease.await() + listOf(indexedFile("stale")) + } + + val indexing = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-a") } + scanStarted.await() + indexing.cancel() + + assertFailsWith { indexing.await() } + assertEquals(emptyList(), indexer.indexedFiles.value) + assertNull(indexer.indexedPath.value) + assertFalse(indexer.isIndexing.value, "cancellation must release the indexing indicator") + } + + @Test + fun `cancelling a queued request prevents it from running or publishing`() = + runBlocking { + val firstScanStarted = CompletableDeferred() + val releaseFirstScan = CompletableDeferred() + val calls = mutableListOf() + val indexer = + FileIndexer { projectPath -> + calls += projectPath + if (projectPath == "project-a") { + firstScanStarted.complete(Unit) + releaseFirstScan.await() + } + listOf(indexedFile(projectPath)) + } + + val first = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-a") } + firstScanStarted.await() + val queued = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-b") } + + queued.cancel() + assertFailsWith { queued.await() } + + releaseFirstScan.complete(Unit) + first.await() + + assertEquals(listOf("project-a"), calls) + assertEquals("project-a", indexer.indexedPath.value) + } + + @Test + fun `cancelling the lock holder releases the queued request`() = + runBlocking { + val firstScanStarted = CompletableDeferred() + val neverRelease = CompletableDeferred() + val calls = mutableListOf() + val indexer = + FileIndexer { projectPath -> + calls += projectPath + if (projectPath == "project-a") { + firstScanStarted.complete(Unit) + neverRelease.await() + } + listOf(indexedFile(projectPath)) + } + + val first = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-a") } + firstScanStarted.await() + val second = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-b") } + + first.cancel() + assertFailsWith { first.await() } + second.await() + assertFalse(indexer.isIndexing.value, "completed or failed scans must release the indexing indicator") + + assertEquals(listOf("project-a", "project-b"), calls) + assertEquals("project-b", indexer.indexedPath.value) + } + + @Test + fun `a non cancellation scan failure releases the queued request`() = + runBlocking { + val firstScanStarted = CompletableDeferred() + val releaseFailure = CompletableDeferred() + val calls = mutableListOf() + val indexer = + FileIndexer { projectPath -> + calls += projectPath + if (projectPath == "project-a") { + firstScanStarted.complete(Unit) + releaseFailure.await() + error("scan failed") + } + listOf(indexedFile(projectPath)) + } + + val first = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-a") } + firstScanStarted.await() + val second = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-b") } + + assertFalse(second.isCompleted, "the second request must remain queued behind the failed scan") + releaseFailure.complete(Unit) + first.await() + second.await() + assertFalse(indexer.isIndexing.value, "completed or failed scans must release the indexing indicator") + + assertEquals(listOf("project-a", "project-b"), calls) + assertEquals("project-b", indexer.indexedPath.value) + assertEquals(listOf("project-b/file.kt"), indexer.indexedFiles.value.map { it.path }) + } + + @Test + fun `rapid A B C requests serialize and publish the last completed project`() = + runBlocking { + val firstScanStarted = CompletableDeferred() + val releaseFirstScan = CompletableDeferred() + val calls = mutableListOf() + val indexer = + FileIndexer { projectPath -> + calls += projectPath + if (projectPath == "project-a") { + firstScanStarted.complete(Unit) + releaseFirstScan.await() + } + listOf(indexedFile(projectPath)) + } + + val a = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-a") } + firstScanStarted.await() + val b = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-b") } + val c = async(start = CoroutineStart.UNDISPATCHED) { indexer.indexProject("project-c") } + + releaseFirstScan.complete(Unit) + a.await() + b.await() + c.await() + + assertEquals(listOf("project-a", "project-b", "project-c"), calls) + assertEquals("project-c", indexer.indexedPath.value) + assertEquals(listOf("project-c/file.kt"), indexer.indexedFiles.value.map { it.path }) + } + + private fun indexedFile(project: String) = + IndexedFile( + name = "file.kt", + path = "$project/file.kt", + relativePath = "file.kt", + ) +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchIsolationTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchIsolationTest.kt new file mode 100644 index 0000000000..26f639707f --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchIsolationTest.kt @@ -0,0 +1,89 @@ +package ai.rever.boss.search + +import ai.rever.boss.components.dialogs.SpotlightDialogState +import kotlinx.coroutines.runBlocking +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GlobalSearchIsolationTest { + @BeforeTest + fun setUp() { + SearchSources.clearForTests() + } + + @AfterTest + fun tearDown() { + SearchSources.clearForTests() + } + + @Test + fun `each search sees only its supplied project snapshot`() = + runBlocking { + val projectA = listOf(indexedFile("/projects/a", "alpha.kt")) + val projectB = listOf(indexedFile("/projects/b", "beta.kt")) + + val aResults = GlobalSearchService.search("alpha", "window-a", projectA).fileResults() + val bResults = GlobalSearchService.search("beta", "window-b", projectB).fileResults() + + assertEquals(listOf("/projects/a/alpha.kt"), aResults.map { it.path }) + assertEquals(listOf("/projects/b/beta.kt"), bResults.map { it.path }) + assertTrue(aResults.none { it.path.startsWith("/projects/b") }) + assertTrue(bResults.none { it.path.startsWith("/projects/a") }) + } + + @Test + fun `dialog states do not share results categories or selection`() { + val first = SpotlightDialogState() + val second = SpotlightDialogState() + val firstResult = SearchResult.FileResult("alpha.kt", "/a/alpha.kt", "alpha.kt", 10, emptyList()) + val secondResult = SearchResult.FileResult("beta.kt", "/b/beta.kt", "beta.kt", 20, emptyList()) + + first.query = "alpha" + first.results = listOf(firstResult) + first.activeCategory = SearchCategory.FILES + first.selectedIndex = 0 + first.isSearching = true + + second.query = "beta" + second.results = listOf(secondResult) + second.activeCategory = SearchCategory.ALL + second.selectedIndex = 0 + + assertEquals("alpha", first.query) + assertEquals(listOf(firstResult), first.results) + assertEquals(SearchCategory.FILES, first.activeCategory) + assertTrue(first.isSearching) + assertEquals("beta", second.query) + assertEquals(listOf(secondResult), second.results) + assertEquals(SearchCategory.ALL, second.activeCategory) + assertEquals(0, second.selectedIndex) + } + + @Test + fun `dialog receives a window-owned indexer instead of constructing one per open`() { + val dialogSource = + File("src/commonMain/kotlin/ai/rever/boss/components/dialogs/GlobalSearchDialog.kt").readText() + val hostSource = File("src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt").readText() + + assertTrue(dialogSource.contains("fileIndexer: FileIndexer")) + assertTrue(dialogSource.contains("remember(projectPath) { SpotlightDialogState() }")) + assertFalse(dialogSource.contains("fileIndexer.indexProject(")) + assertTrue(hostSource.contains("onIndexProject = { state.spotlightFileIndexes.ensureIndexed")) + assertFalse(dialogSource.contains("FileIndexer(")) + val ownerLookup = "state.spotlightFileIndexes.indexerFor(selectedProject.path)" + assertTrue(hostSource.contains("val spotlightFileIndexer = $ownerLookup")) + assertTrue(hostSource.contains("fileIndexer = spotlightFileIndexer")) + } + + private fun List.fileResults() = filterIsInstance() + + private fun indexedFile( + project: String, + name: String, + ) = IndexedFile(name = name, path = "$project/$name", relativePath = name) +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchNewSourcesTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchNewSourcesTest.kt index a88c511463..a2c955d7c8 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchNewSourcesTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchNewSourcesTest.kt @@ -30,21 +30,15 @@ class GlobalSearchNewSourcesTest { @BeforeTest fun setUp() { SearchSources.clearForTests() - GlobalSearchService.clearResults() - // Files leak in from whatever indexed a project earlier in this JVM otherwise, and these - // tests read whole result lists in places. - GlobalSearchService.clearIndex() - GlobalSearchService.setActiveCategory(SearchCategory.ALL) } @AfterTest fun tearDown() { SearchSources.clearForTests() - GlobalSearchService.clearResults() - GlobalSearchService.setActiveCategory(SearchCategory.ALL) } - private fun searchFor(query: String): List = runBlocking { GlobalSearchService.search(query, WINDOW) } + @Suppress("MaxLineLength") + private fun searchFor(query: String): List = runBlocking { GlobalSearchService.search(query, WINDOW, emptyList()) } private inline fun resultsOf(q: String) = searchFor(q).filterIsInstance() @@ -432,20 +426,11 @@ class GlobalSearchNewSourcesTest { @Test fun `results from the new sources survive the category filter`() { registerTools(tool("bookmarks", "Bookmarks")) - searchFor("bookmark") - - // The category is global state on a singleton, so the restore goes in a finally: an - // assertion failing here would otherwise leave a filter set for whatever runs next in - // this JVM, and the failure would be reported against that test instead of this one. - try { - GlobalSearchService.setActiveCategory(SearchCategory.TOOLS) - val filtered = GlobalSearchService.getFilteredResults() - - assertTrue(filtered.isNotEmpty(), "the TOOLS chip must show tools") - assertTrue(filtered.all { it.category == SearchCategory.TOOLS }) - } finally { - GlobalSearchService.setActiveCategory(SearchCategory.ALL) - } + val results = searchFor("bookmark") + val filtered = GlobalSearchService.getFilteredResults(results, SearchCategory.TOOLS) + + assertTrue(filtered.isNotEmpty(), "the TOOLS chip must show tools") + assertTrue(filtered.all { it.category == SearchCategory.TOOLS }) } @Test diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchOrderingTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchOrderingTest.kt index 8453743b57..67cfabb92a 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchOrderingTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchOrderingTest.kt @@ -19,15 +19,11 @@ class GlobalSearchOrderingTest { @BeforeTest fun setUp() { SearchSources.clearForTests() - GlobalSearchService.clearResults() - GlobalSearchService.setActiveCategory(SearchCategory.ALL) } @AfterTest fun tearDown() { SearchSources.clearForTests() - GlobalSearchService.clearResults() - GlobalSearchService.setActiveCategory(SearchCategory.ALL) } private companion object { @@ -62,8 +58,8 @@ class GlobalSearchOrderingTest { SearchSources.registerTools(WINDOW) { listOf(ToolSearchRecord(panelId = "atlas", label = "Atlas")) } registerSettings("Atlas") - runBlocking { GlobalSearchService.search("atlas", WINDOW) } - val ordered = GlobalSearchService.getFilteredResults() + val results = runBlocking { GlobalSearchService.search("atlas", WINDOW, emptyList()) } + val ordered = GlobalSearchService.getFilteredResults(results, SearchCategory.ALL) assertTrue(ordered.isNotEmpty()) assertEquals(SearchCategory.TOOLS, ordered.first().category, "Tools must lead the All view") @@ -81,8 +77,8 @@ class GlobalSearchOrderingTest { } registerSettings("Atlas", "Atlas Bar") - runBlocking { GlobalSearchService.search("atlas", WINDOW) } - val categories = GlobalSearchService.getFilteredResults().map { it.category } + val results = runBlocking { GlobalSearchService.search("atlas", WINDOW, emptyList()) } + val categories = GlobalSearchService.getFilteredResults(results, SearchCategory.ALL).map { it.category } assertEquals(categories.distinct(), categories.distinct().sortedBy { it.ordinal }, "declaration order") assertEquals(categories.distinct().size, categories.zipWithNext().count { it.first != it.second } + 1) @@ -94,8 +90,8 @@ class GlobalSearchOrderingTest { // which is what the exact-label test in GlobalSearchNewSourcesTest depends on. registerSettings("Passkeys", "Passkeys Extra Long") - runBlocking { GlobalSearchService.search("passkeys", WINDOW) } - val scores = GlobalSearchService.getFilteredResults().map { it.score } + val results = runBlocking { GlobalSearchService.search("passkeys", WINDOW, emptyList()) } + val scores = GlobalSearchService.getFilteredResults(results, SearchCategory.ALL).map { it.score } assertEquals(scores.sortedDescending(), scores) } @@ -103,17 +99,11 @@ class GlobalSearchOrderingTest { @Test fun `filtering to one category keeps pure score order`() { registerSettings("Passkeys", "Passkeys Extra Long") - runBlocking { GlobalSearchService.search("passkeys", WINDOW) } + val results = runBlocking { GlobalSearchService.search("passkeys", WINDOW, emptyList()) } + val scores = GlobalSearchService.getFilteredResults(results, SearchCategory.SETTINGS).map { it.score } - try { - GlobalSearchService.setActiveCategory(SearchCategory.SETTINGS) - val scores = GlobalSearchService.getFilteredResults().map { it.score } - - assertEquals(scores.sortedDescending(), scores) - assertTrue(scores.isNotEmpty()) - } finally { - GlobalSearchService.setActiveCategory(SearchCategory.ALL) - } + assertEquals(scores.sortedDescending(), scores) + assertTrue(scores.isNotEmpty()) } @Test diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchTabsTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchTabsTest.kt index 80177df930..f6df03fc66 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchTabsTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchTabsTest.kt @@ -31,19 +31,17 @@ class GlobalSearchTabsTest { @BeforeTest fun setUp() { TopOfMindStateHolder.updateActiveTabs(emptyList()) - GlobalSearchService.clearResults() - GlobalSearchService.setActiveCategory(SearchCategory.ALL) } @AfterTest fun tearDown() { TopOfMindStateHolder.updateActiveTabs(emptyList()) - GlobalSearchService.clearResults() - GlobalSearchService.setActiveCategory(SearchCategory.ALL) } private fun searchFor(query: String): List = - runBlocking { GlobalSearchService.search(query, WINDOW) }.filterIsInstance() + runBlocking { + GlobalSearchService.search(query, WINDOW, emptyList()) + }.filterIsInstance() private fun fluckTab( id: String, diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SearchSourceRegistrarTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SearchSourceRegistrarTest.kt index dbca8b71a1..a0982184e8 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SearchSourceRegistrarTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SearchSourceRegistrarTest.kt @@ -76,9 +76,6 @@ class SearchSourceRegistrarTest { // absent and wrong for the three below, which exist to exercise the production default. // Those re-clear with useProductionDefaults for themselves. SearchSources.clearForTests() - GlobalSearchService.clearResults() - GlobalSearchService.clearIndex() - GlobalSearchService.setActiveCategory(SearchCategory.ALL) } @AfterTest @@ -87,8 +84,6 @@ class SearchSourceRegistrarTest { tempPagesFile.parentFile?.deleteRecursively() SearchSources.clearForTests() - GlobalSearchService.clearResults() - GlobalSearchService.setActiveCategory(SearchCategory.ALL) } @Test @@ -96,7 +91,7 @@ class SearchSourceRegistrarTest { SettingsSearchIndex.registerWithGlobalSearch() val hit = - runBlocking { GlobalSearchService.search("show title bar", windowId = null) } + runBlocking { GlobalSearchService.search("show title bar", windowId = null, indexedFiles = emptyList()) } .filterIsInstance() .firstOrNull { it.label == "Show Title Bar" } @@ -119,7 +114,7 @@ class SearchSourceRegistrarTest { SettingsSearchIndex.registerWithGlobalSearch() val labels = - runBlocking { GlobalSearchService.search("title bar", windowId = null) } + runBlocking { GlobalSearchService.search("title bar", windowId = null, indexedFiles = emptyList()) } .filterIsInstance() .map { it.label } @@ -134,7 +129,7 @@ class SearchSourceRegistrarTest { SettingsSearchIndex.registerWithGlobalSearch() val labels = - runBlocking { GlobalSearchService.search("passkey", windowId = null) } + runBlocking { GlobalSearchService.search("passkey", windowId = null, indexedFiles = emptyList()) } .filterIsInstance() .map { it.label } @@ -196,8 +191,9 @@ class SearchSourceRegistrarTest { McpToolRegistryImpl.registerProvider(provider) try { val hits = - runBlocking { GlobalSearchService.search("registrar_probe", windowId = null) } - .filterIsInstance() + runBlocking { + GlobalSearchService.search("registrar_probe", windowId = null, indexedFiles = emptyList()) + }.filterIsInstance() assertEquals(listOf("registrar_probe"), hits.map { it.name }) } finally { @@ -223,8 +219,9 @@ class SearchSourceRegistrarTest { } } val hits = - runBlocking { GlobalSearchService.search("registrar probe", windowId = null) } - .filterIsInstance() + runBlocking { + GlobalSearchService.search("registrar probe", windowId = null, indexedFiles = emptyList()) + }.filterIsInstance() assertTrue( hits.any { it.url == PROBE_PAGE }, @@ -239,7 +236,10 @@ class SearchSourceRegistrarTest { fun `an unregistered settings source contributes nothing rather than failing the search`() { // The startup state. Registering is a single call in main(), so the failure mode if it is // ever dropped is silence - worth pinning that it is silence and not a crash. - val results = runBlocking { GlobalSearchService.search("show title bar", windowId = null) } + val results = + runBlocking { + GlobalSearchService.search("show title bar", windowId = null, indexedFiles = emptyList()) + } assertTrue(results.filterIsInstance().isEmpty()) } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SpotlightFileIndexOwnerTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SpotlightFileIndexOwnerTest.kt new file mode 100644 index 0000000000..3973d77dde --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SpotlightFileIndexOwnerTest.kt @@ -0,0 +1,126 @@ +package ai.rever.boss.search + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class SpotlightFileIndexOwnerTest { + @Test + fun `completed index is reused and force reindex scans again`() = + runTest { + var scans = 0 + val owner = + SpotlightFileIndexOwner(backgroundScope) { + FileIndexer { path -> + scans++ + listOf(file(path)) + } + } + val first = owner.indexerFor("/first") + first.indexProject("/first") + val reopened = owner.indexerFor("/first") + reopened.indexProject("/first") + assertSame(first, reopened) + assertEquals(1, scans) + reopened.indexProject("/first", forceReindex = true) + assertEquals(2, scans) + + val replacement = owner.indexerFor("/replacement") + assertNotSame(first, replacement) + assertEquals(emptyList(), first.indexedFiles.value) + replacement.indexProject("/replacement") + assertEquals(listOf("/replacement/file.kt"), replacement.indexedFiles.value.map { it.path }) + } + + @Test + fun `closing and reopening a dialog during a scan retains a single live scan`() = + runTest { + var scans = 0 + val release = CompletableDeferred() + val owner = + SpotlightFileIndexOwner(backgroundScope) { + FileIndexer { path -> + scans++ + release.await() + listOf(file(path)) + } + } + val dialog = launch { owner.ensureIndexed("/project") } + runCurrent() + dialog.cancelAndJoin() + assertTrue(owner.indexerFor("/project").isIndexing.value) + owner.ensureIndexed("/project") + runCurrent() + assertEquals(1, scans) + release.complete(Unit) + runCurrent() + assertEquals(listOf(file("/project")), owner.indexerFor("/project").indexedFiles.value) + owner.ensureIndexed("/project") + runCurrent() + assertEquals(1, scans) + assertFalse(owner.indexerFor("/project").isIndexing.value) + } + + @Test + fun `project replacement cancels an unfinished scan without publishing its files`() = + runTest { + val cancelled = CompletableDeferred() + val owner = + SpotlightFileIndexOwner(backgroundScope) { + FileIndexer { path -> + if (path == "/first") { + try { + CompletableDeferred().await() + } finally { + cancelled.complete(Unit) + } + } + listOf(file(path)) + } + } + val first = owner.indexerFor("/first") + owner.ensureIndexed("/first") + runCurrent() + owner.ensureIndexed("/second") + runCurrent() + assertTrue(cancelled.isCompleted) + assertFalse(first.isIndexing.value) + assertEquals(emptyList(), first.indexedFiles.value) + assertEquals(listOf(file("/second")), owner.indexerFor("/second").indexedFiles.value) + } + + @Test + fun `window scope cancellation stops its retained scan`() = + runTest { + val windowJob = Job(backgroundScope.coroutineContext[Job]) + val scope = CoroutineScope(backgroundScope.coroutineContext + windowJob) + val owner = + SpotlightFileIndexOwner(scope) { + FileIndexer { + CompletableDeferred().await() + emptyList() + } + } + val indexer = owner.indexerFor("/project") + owner.ensureIndexed("/project") + runCurrent() + assertTrue(indexer.isIndexing.value) + windowJob.cancel() + runCurrent() + assertFalse(indexer.isIndexing.value) + assertEquals(emptyList(), indexer.indexedFiles.value) + } + + private fun file(path: String) = IndexedFile("file.kt", "$path/file.kt", "file.kt") +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SpotlightSearchEffectTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SpotlightSearchEffectTest.kt new file mode 100644 index 0000000000..c3219a32a1 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/SpotlightSearchEffectTest.kt @@ -0,0 +1,102 @@ +package ai.rever.boss.search + +import ai.rever.boss.components.dialogs.SpotlightDialogState +import ai.rever.boss.components.dialogs.SpotlightSearchEffect +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals + +class SpotlightSearchEffectTest { + @Test + fun `index completion refreshes unchanged query and replacement session starts its own search`() = + runBlocking { + SearchSources.clearForTests() + val clock = BroadcastFrameClock() + withContext(clock) { + val recomposer = Recomposer(coroutineContext) + val runner = launch { recomposer.runRecomposeAndApplyChanges() } + val frames = + launch { + while (isActive) { + clock.sendFrame(System.nanoTime()) + delay(1) + } + } + val composition = Composition(NoNodes(), recomposer) + val first = SpotlightDialogState().apply { query = "needle" } + val session = mutableStateOf(first) + val files = mutableStateOf>(emptyList()) + try { + composition.setContent { SpotlightSearchEffect(session.value, "window", files.value) } + // Let the first search finish against the cold, empty snapshot. + delay(150) + assertEquals(emptyList(), first.results.filterIsInstance()) + files.value = listOf(IndexedFile("needle.kt", "/a/needle.kt", "needle.kt")) + Snapshot.sendApplyNotifications() + withTimeout(5_000) { + while (first.results.filterIsInstance().isEmpty()) delay(10) + } + assertEquals( + "/a/needle.kt", + first.results + .filterIsInstance() + .single() + .path, + ) + + // The query and file snapshot are identical; session identity must still restart the effect. + val second = SpotlightDialogState().apply { query = "needle" } + session.value = second + Snapshot.sendApplyNotifications() + withTimeout(5_000) { + while (second.results.filterIsInstance().isEmpty()) delay(10) + } + assertEquals(first.results, second.results) + assertEquals("needle", first.query) + } finally { + composition.dispose() + recomposer.cancel() + runner.cancelAndJoin() + frames.cancelAndJoin() + SearchSources.clearForTests() + } + } + } + + private class NoNodes : AbstractApplier(Unit) { + override fun insertTopDown( + index: Int, + instance: Unit, + ) = Unit + + override fun insertBottomUp( + index: Int, + instance: Unit, + ) = Unit + + override fun remove( + index: Int, + count: Int, + ) = Unit + + override fun move( + from: Int, + to: Int, + count: Int, + ) = Unit + + override fun onClear() = Unit + } +} From d9c0cbbddb8ba4a321ddf8b510a68c174f13181c Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 20:19:55 -0700 Subject: [PATCH 21/49] fix(browser): keep virtual spreadsheet gestures page-owned --- .../resources/browser/swipe-nav.js | 23 ++++++++++++-- scripts/test/test-swipe-nav.js | 31 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/composeApp/src/desktopMain/resources/browser/swipe-nav.js b/composeApp/src/desktopMain/resources/browser/swipe-nav.js index 31dd364b1b..95732c9b35 100644 --- a/composeApp/src/desktopMain/resources/browser/swipe-nav.js +++ b/composeApp/src/desktopMain/resources/browser/swipe-nav.js @@ -72,6 +72,8 @@ // The scroll chain under the first event owns the whole gesture. Keeping the initial path // matters when a vertical first delta turns horizontal after the pointer has moved. var scrollPath = null; + // Retain only the latest event to observe cancellation by later window listeners. + var lastWheelEvent = null; // Latched the first time this gesture is past COMMIT_PX with enough events to be real. // // From that point the vertical tiers stop being asked. Vertical is a PATH LENGTH, so it only @@ -141,10 +143,13 @@ if (!el || el.nodeType !== 1) { continue; } - var range = el.scrollWidth - el.clientWidth; - if (range <= 1) { - continue; + // Virtual grids and canvases often scroll a model or a sibling scrollbar instead + // of this element. Their wheel gesture belongs to the page even at an edge. + var role = typeof el.getAttribute === 'function' ? el.getAttribute('role') : null; + if (el.tagName === 'CANVAS' || role === 'grid' || role === 'treegrid') { + return true; } + var range = el.scrollWidth - el.clientWidth; var overflowX = ''; var cssDirection = 'ltr'; var overscrollX = 'auto'; @@ -156,6 +161,12 @@ } catch (e2) { overflowX = ''; } + if (overscrollX === 'contain' || overscrollX === 'none') { + return true; + } + if (range <= 1) { + continue; + } // The root needs a special case - it computes `overflow-x: visible` on an ordinary page // even though the viewport scrolls - but it must NOT skip the test entirely, which is // what `el === scroller` alone did. `html { overflow-x: hidden }` with content wider @@ -314,6 +325,7 @@ rejected = false; direction = 0; scrollPath = null; + lastWheelEvent = null; reachedCommit = false; nativeGestureId = null; } @@ -368,6 +380,9 @@ // the direction lost its history entry, or if state went away entirely. // function decide() { + // A page may register a window bubble listener after ours. Its preventDefault is + // visible now, after dispatch, even though it was false inside onWheel. + if (lastWheelEvent && lastWheelEvent.defaultPrevented) abandon(); if (rejected || direction === 0 || eventCount < MIN_EVENTS || !available(direction)) { return; } @@ -416,6 +431,8 @@ } nativeGestureId = activeId; + if (lastWheelEvent && lastWheelEvent.defaultPrevented) abandon(); + lastWheelEvent = event; if (rejected) { return; } diff --git a/scripts/test/test-swipe-nav.js b/scripts/test/test-swipe-nav.js index 5f19841c2c..99b339f11e 100644 --- a/scripts/test/test-swipe-nav.js +++ b/scripts/test/test-swipe-nav.js @@ -99,6 +99,7 @@ function newPage(js, options = {}) { children: [], parentNode: null, setAttribute() {}, + getAttribute(name) { return this[name] || null; }, appendChild(child) { child.parentNode = this; this.children.push(child); @@ -231,6 +232,7 @@ function newPage(js, options = {}) { }, }; (listeners.wheel || []).forEach((f) => f(event)); + if (eventOptions.preventAfterObserver) event.defaultPrevented = true; }; return { @@ -789,6 +791,35 @@ console.log("\nChrome's cancellation tiers (history_swiper.mm)"); eq('and a clean swipe still is not', p.navigated, ['back']); } +console.log('\nvirtual spreadsheet ownership'); +for (const props of [{ tagName: 'CANVAS' }, { role: 'grid' }, { role: 'treegrid' }]) { + const p = newPage(js); + const surface = p.element(props); + p.swipe(12, -10, 0, surface); + p.settle(); + eq('virtual surface retains gesture: ' + JSON.stringify(props), p.navigated, []); +} +{ + const p = newPage(js, { root: { overscrollBehaviorX: 'none' } }); + p.swipe(12, -10); + p.settle(); + eq('root overscroll opt-out without a DOM scroll range', p.navigated, []); +} +{ + const p = newPage(js); + p.wheel(-10, 0, undefined, { preventAfterObserver: true }); + p.swipe(12, -10); + p.settle(); + eq('late page cancellation stays latched through later uncancelled events', p.navigated, []); +} +{ + const p = newPage(js); + p.swipe(12, -10); + p.wheel(-10, 0, undefined, { preventAfterObserver: true }); + p.settle(); + eq('late cancellation of final event prevents release navigation', p.navigated, []); +} + console.log('\nswitching it off while a page is open'); { const p = newPage(js, { state: { enabled: false, back: true, forward: true } }); From e1d495fc0bbc810d64a634ead052fb43c0e72f53 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 20:33:58 -0700 Subject: [PATCH 22/49] fix(browser): allow new outward gestures at Google Sheets edges --- .../resources/browser/swipe-nav.js | 53 ++++++++++++++++--- scripts/test/test-swipe-nav.js | 37 +++++++++++-- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/composeApp/src/desktopMain/resources/browser/swipe-nav.js b/composeApp/src/desktopMain/resources/browser/swipe-nav.js index 95732c9b35..2627f2c8d9 100644 --- a/composeApp/src/desktopMain/resources/browser/swipe-nav.js +++ b/composeApp/src/desktopMain/resources/browser/swipe-nav.js @@ -74,6 +74,10 @@ var scrollPath = null; // Retain only the latest event to observe cancellation by later window listeners. var lastWheelEvent = null; + var sheetBoundary = null; + var sheetEdgeNavigation = false; + var capturedWheel = null; + var capturedSheetBoundary = null; // Latched the first time this gesture is past COMMIT_PX with enough events to be real. // // From that point the vertical tiers stop being asked. Vertical is a PATH LENGTH, so it only @@ -135,6 +139,32 @@ return path; } + // Sheets renders cells on a canvas, with a sibling native scrollbar. Read only geometry, + // never cells. Its document overscroll policy and cancelled wheels also cover the canvas + // at its boundary, so this explicit adapter allows a NEW outward gesture there. + function sheetsBoundary(path) { + if (!w.location || w.location.hostname !== 'docs.google.com' || + w.location.pathname.indexOf('/spreadsheets/') !== 0) return null; + for (var i = 0; i < path.length; i++) { + var el = path[i]; + if (!el || typeof el.closest !== 'function') continue; + var grid = el.closest('.grid-container'); + if (!grid) continue; + var bar = grid.querySelector('.native-scrollbar-x'); + if (!bar || bar.clientWidth <= 0 || bar.scrollWidth < bar.clientWidth) return null; + var style = w.getComputedStyle(bar); + return { left: bar.scrollLeft, range: bar.scrollWidth - bar.clientWidth, + rtl: style.direction === 'rtl' }; + } + return null; + } + + function sheetCanScroll(boundary, dir) { + return boundary.rtl + ? (dir < 0 ? boundary.left > -boundary.range + 1 : boundary.left < -1) + : (dir < 0 ? boundary.left > 1 : boundary.left < boundary.range - 1); + } + function chainCanScroll(path, dir) { var doc = w.document; var scroller = doc.scrollingElement || doc.documentElement; @@ -326,6 +356,8 @@ direction = 0; scrollPath = null; lastWheelEvent = null; + sheetBoundary = null; + sheetEdgeNavigation = false; reachedCommit = false; nativeGestureId = null; } @@ -382,7 +414,7 @@ function decide() { // A page may register a window bubble listener after ours. Its preventDefault is // visible now, after dispatch, even though it was false inside onWheel. - if (lastWheelEvent && lastWheelEvent.defaultPrevented) abandon(); + if (!sheetBoundary && lastWheelEvent && lastWheelEvent.defaultPrevented) abandon(); if (rejected || direction === 0 || eventCount < MIN_EVENTS || !available(direction)) { return; } @@ -431,20 +463,22 @@ } nativeGestureId = activeId; - if (lastWheelEvent && lastWheelEvent.defaultPrevented) abandon(); + if (!scrollPath) { + scrollPath = eventPath(event); + sheetBoundary = capturedWheel === event ? capturedSheetBoundary : sheetsBoundary(scrollPath); + } + + if (!sheetBoundary && lastWheelEvent && lastWheelEvent.defaultPrevented) abandon(); lastWheelEvent = event; if (rejected) { return; } // Run in bubble phase so a page widget gets the first chance to claim a synthetic or // JavaScript-driven horizontal scroller with preventDefault(). - if (event.defaultPrevented) { + if (event.defaultPrevented && !sheetBoundary) { abandon(); return; } - if (!scrollPath) { - scrollPath = eventPath(event); - } var dx = event.deltaX || 0; var dy = event.deltaY || 0; // Vertical travel counts from the first event of the gesture, including events with @@ -482,7 +516,8 @@ direction = dir; // Decided once per gesture and latched, both of these: which way it goes, and // whether the page wanted the scroll for itself. - if (chainCanScroll(scrollPath, dir) || !available(dir)) { + sheetEdgeNavigation = sheetBoundary !== null && !sheetCanScroll(sheetBoundary, dir); + if ((sheetBoundary ? !sheetEdgeNavigation : chainCanScroll(scrollPath, dir)) || !available(dir)) { abandon(); return; } @@ -531,6 +566,10 @@ // Bubble phase lets target/page handlers claim custom scrollers with preventDefault first; // passive keeps this observer off Chromium's scroll-blocking path. + w.addEventListener('wheel', function (event) { + capturedWheel = event; + capturedSheetBoundary = switchedOff() || event.deltaMode !== 0 ? null : sheetsBoundary(eventPath(event)); + }, { capture: true, passive: true }); w.addEventListener('wheel', onWheel, { capture: false, passive: true }); // pagehide is NOT routed through decide() - the page is already unloading, so navigating // it anywhere is moot at best; this only tears down the affordance so nothing outlives the diff --git a/scripts/test/test-swipe-nav.js b/scripts/test/test-swipe-nav.js index 99b339f11e..bad4091d82 100644 --- a/scripts/test/test-swipe-nav.js +++ b/scripts/test/test-swipe-nav.js @@ -156,6 +156,7 @@ function newPage(js, options = {}) { }; const shadowRoots = []; sandbox.window = { + location: options.location, performance: { timeOrigin: 0 }, addEventListener: add, setTimeout: sandbox.setTimeout, @@ -231,7 +232,10 @@ function newPage(js, options = {}) { preventedDefaults++; }, }; - (listeners.wheel || []).forEach((f) => f(event)); + (listeners.wheel || []).forEach((f) => { + f(event); + if (f === (listeners.wheel || [])[0] && eventOptions.afterCapture) eventOptions.afterCapture(); + }); if (eventOptions.preventAfterObserver) event.defaultPrevented = true; }; @@ -342,8 +346,8 @@ console.log( console.log('\nwiring'); { const p = newPage(js); - check('installs exactly one wheel listener', p.installed() === 1, p.installed()); - const wheelReg = p.registrations.find((r) => r.type === 'wheel'); + check('installs capture snapshot and bubble observer once', p.installed() === 2, p.installed()); + const wheelReg = p.registrations.find((r) => r.type === 'wheel' && r.opts.capture === false); eq('listens in bubble phase and passive', wheelReg.opts, { capture: false, passive: true }); check( 'script names the host bridge property', @@ -791,6 +795,33 @@ console.log("\nChrome's cancellation tiers (history_swiper.mm)"); eq('and a clean swipe still is not', p.navigated, ['back']); } +console.log('\nGoogle Sheets boundary adapter'); +for (const [left, dx, want] of [[0,-10,['back']],[0,10,[]],[100,-10,[]],[100,10,[]],[200,10,['forward']],[200,-10,[]]]) { + const p = newPage(js, { location: { hostname:'docs.google.com', pathname:'/spreadsheets/d/test/edit' } }); + const bar = p.element({ clientWidth:100, scrollWidth:300, scrollLeft:left }); + const grid = { querySelector: () => bar }; + const canvas = p.element({ tagName:'CANVAS', closest: () => grid }); + for (let i=0;i<12;i++) p.wheel(dx,0,canvas,{defaultPrevented:true}); + p.settle(); + eq('Sheets starts at '+left+' delta '+dx, p.navigated, want); +} +{ + const p = newPage(js, { location: { hostname:'docs.google.com', pathname:'/spreadsheets/d/test/edit' } }); + const bar = p.element({ clientWidth:100, scrollWidth:300, scrollLeft:10 }); + const canvas = p.element({ tagName:'CANVAS', closest: () => ({querySelector:()=>bar}) }); + p.wheel(-10,0,canvas,{afterCapture:()=>{bar.scrollLeft=0;},defaultPrevented:true}); + p.swipe(12,-10,0,canvas);p.settle(); + eq('Sheets reaching edge during first event remains page-owned',p.navigated,[]); + p.newGesture();p.swipe(12,-10,0,canvas);p.settle(); + eq('Sheets next outward contact at edge navigates',p.navigated,['back']); +} +{ + const p = newPage(js, { location: { hostname:'other.example', pathname:'/spreadsheets/d/test/edit' } }); + const canvas=p.element({tagName:'CANVAS',closest:()=>({querySelector:()=>p.element({clientWidth:100,scrollWidth:300})})}); + p.swipe(12,-10,0,canvas);p.settle(); + eq('Sheets adapter does not override another origin',p.navigated,[]); +} + console.log('\nvirtual spreadsheet ownership'); for (const props of [{ tagName: 'CANVAS' }, { role: 'grid' }, { role: 'treegrid' }]) { const p = newPage(js); From 89995116a141e7390d5c148c573e4a5daeeac784 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 20:46:47 -0700 Subject: [PATCH 23/49] ui: refine MCP policy controls and dialog --- .../bars/horizontal/BossBottomBar.kt | 13 +- .../dialogs/McpPolicyManagerDialog.kt | 122 +++++++++++++++--- 2 files changed, 110 insertions(+), 25 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt index 1f9fdc71b9..92919404c2 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt @@ -42,6 +42,7 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Tune import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -353,12 +354,12 @@ private fun McpPolicyManagerStatusItem(persistedPolicyConfig: McpToolPolicyConfi var showPolicyManager by remember { mutableStateOf(false) } val ruleCount = persistedPolicyConfig.rules.size if (ruleCount > 0 || allTools.isNotEmpty()) { - androidx.compose.material.TextButton(onClick = { showPolicyManager = true }) { - Text( - if (ruleCount > 0) "Persisted MCP policies ($ruleCount)" else "Set MCP tool policies", - color = BossTheme.colors.textSecondary, - ) - } + BossActionButton( + imageVector = Icons.Outlined.Tune, + text = if (ruleCount > 0) "Tool policies ($ruleCount)" else "Tool policies", + color = BossTheme.colors.textSecondary, + onClick = { showPolicyManager = true }, + ) } if (showPolicyManager) { val disabledToolNames by McpToolRegistryImpl.disabledToolNames.collectAsState() diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt index 018c2537b6..dfc3016719 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt @@ -8,6 +8,7 @@ import ai.rever.boss.plugin.api.McpToolArgs import ai.rever.boss.plugin.ui.BossColorScheme import ai.rever.boss.plugin.ui.BossDialog import ai.rever.boss.plugin.ui.BossTheme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -23,9 +24,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.OutlinedTextField import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton +import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -98,35 +102,71 @@ fun McpPolicyManagerDialog( // (McpPolicyEngine.policyFor). Tracks at most one row at a time - switching to a different // row's button, or dismissing, drops any pending confirmation rather than carrying it silently. var confirmingDeny by remember { mutableStateOf(null) } + var query by remember { mutableStateOf("") } + val filteredRules = rules.filterKeys { it.contains(query.trim(), ignoreCase = true) } + val filteredTools = + availableTools.filter { + it.matchesPolicyQuery(query) + } BossDialog( onDismissRequest = onDismiss, properties = DialogProperties(), ) { Surface( - modifier = Modifier.widthIn(max = maxWidth).width(480.dp).heightIn(max = maxHeight), + modifier = Modifier.widthIn(max = maxWidth).width(600.dp).heightIn(max = maxHeight), shape = RoundedCornerShape(radii.dialog), color = colors.panel, ) { Column(modifier = Modifier.padding(24.dp)) { Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { Text( - text = "Persistent MCP Tool Policies", - fontSize = 18.sp, + text = "MCP tool policies", + fontSize = 20.sp, fontWeight = FontWeight.Bold, color = colors.textPrimary, ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = - "Saved from \"Always Allow\" / \"Always Deny\" in the tool approval dialog. " + - "Resetting a tool removes its saved rule - the next mutating call is " + - "governed by the default policy again, asking unless that default is " + - "itself Allow or Deny.", + text = "Manage how tools request permission. Saved rules apply across agents and restarts.", fontSize = 12.sp, color = colors.textSecondary, ) Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = query, + onValueChange = { + query = it + confirmingDeny = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Find a tool or provider", fontSize = 12.sp) }, + shape = RoundedCornerShape(8.dp), + colors = + TextFieldDefaults.outlinedTextFieldColors( + textColor = colors.textPrimary, + cursorColor = colors.signal, + focusedBorderColor = colors.signal, + unfocusedBorderColor = colors.textSecondary.copy(alpha = 0.25f), + placeholderColor = colors.textSecondary, + backgroundColor = colors.textSecondary.copy(alpha = 0.04f), + ), + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Saved rules · ${rules.size}", + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = colors.textPrimary, + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Reset removes a saved rule. Default policy and session trust then apply.", + fontSize = 11.sp, + color = colors.textSecondary, + ) + Spacer(modifier = Modifier.height(8.dp)) // The surrounding body scrolls as one region; Close stays outside it. Column( @@ -134,16 +174,23 @@ fun McpPolicyManagerDialog( Modifier .fillMaxWidth(), ) { - if (rules.isEmpty()) { + if (filteredRules.isEmpty()) { Text( - text = "No saved rules. Default policies and session trust still apply.", + text = emptyRulesMessage(rules.isEmpty()), fontSize = 13.sp, color = colors.textSecondary, ) } else { - rules.toSortedMap().forEach { (toolName, action) -> + filteredRules.toSortedMap().forEach { (toolName, action) -> Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .background( + color = colors.textSecondary.copy(alpha = 0.04f), + shape = RoundedCornerShape(8.dp), + ).padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { @@ -197,7 +244,7 @@ fun McpPolicyManagerDialog( } } else { TextButton(onClick = { revoke() }) { - Text("Reset", fontSize = 12.sp) + Text("Reset", fontSize = 12.sp, color = colors.signal) } } } @@ -206,7 +253,7 @@ fun McpPolicyManagerDialog( Spacer(modifier = Modifier.height(20.dp)) Text( - text = "Set a rule proactively", + text = "Available tools · ${availableTools.size}", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.textPrimary, @@ -214,17 +261,24 @@ fun McpPolicyManagerDialog( Spacer(modifier = Modifier.height(4.dp)) Text( text = - "For a registered tool without a saved rule - no need to wait for an approval " + - "prompt to lock one down. A tool name, not a specific plugin: another " + - "plugin that later registers the same name inherits this rule too.", + "Choose Allow or Deny for a tool without a saved rule. " + + "Rules follow the tool name, including replacement plugins.", fontSize = 11.sp, color = colors.textSecondary, ) Spacer(modifier = Modifier.height(8.dp)) - ProactivePolicySectionContent(availableTools, onSetPolicy, onRefreshCandidates, colors) + FilteredPolicyCandidates( + filteredTools, + availableTools.isNotEmpty(), + onSetPolicy, + onRefreshCandidates, + colors, + ) } } Spacer(modifier = Modifier.height(16.dp)) + Divider(color = colors.textSecondary.copy(alpha = 0.15f)) + Spacer(modifier = Modifier.height(12.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Button( onClick = onDismiss, @@ -238,6 +292,27 @@ fun McpPolicyManagerDialog( } } +private fun emptyRulesMessage(noSavedRules: Boolean): String = + if (noSavedRules) "No saved rules. Default policies and session trust still apply." else "No matching saved rules." + +private fun McpToolIdentity.matchesPolicyQuery(query: String): Boolean = + toolName.contains(query.trim(), ignoreCase = true) || providerId.contains(query.trim(), ignoreCase = true) + +@Composable +private fun FilteredPolicyCandidates( + tools: List, + hasAvailableTools: Boolean, + onSetPolicy: suspend (McpToolIdentity, McpPolicyAction) -> McpProactivePolicyOutcome, + onRefreshCandidates: () -> Unit, + colors: BossColorScheme, +) { + if (tools.isEmpty() && hasAvailableTools) { + Text("No matching tools.", fontSize = 12.sp, color = colors.textSecondary) + } else { + ProactivePolicySectionContent(tools, onSetPolicy, onRefreshCandidates, colors) + } +} + /** * A tool the policy engine can name a rule for - just enough identity for this dialog. * @@ -320,7 +395,16 @@ private fun ProactivePolicyRow( onSet: (McpPolicyAction) -> Unit, colors: BossColorScheme, ) { - Column(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .background( + color = colors.textSecondary.copy(alpha = 0.04f), + shape = RoundedCornerShape(8.dp), + ).padding(horizontal = 12.dp, vertical = 8.dp), + ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.weight(1f)) { Text( From 54ab77f61f5cc1e122910bf6117d1e68002a9ca5 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 20:55:14 -0700 Subject: [PATCH 24/49] ui: group MCP policies and use staged radio choices --- .../dialogs/McpPolicyManagerDialog.kt | 177 ++++++++++-------- .../dialogs/McpProactivePolicyDialogTest.kt | 4 + 2 files changed, 108 insertions(+), 73 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt index dfc3016719..9975475da8 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt @@ -8,6 +8,7 @@ import ai.rever.boss.plugin.api.McpToolArgs import ai.rever.boss.plugin.ui.BossColorScheme import ai.rever.boss.plugin.ui.BossDialog import ai.rever.boss.plugin.ui.BossTheme +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -20,12 +21,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Divider import androidx.compose.material.OutlinedTextField +import androidx.compose.material.RadioButton +import androidx.compose.material.RadioButtonDefaults import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton @@ -40,6 +45,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -350,11 +356,11 @@ private fun ProactivePolicySectionContent( // Like revoke feedback, retain only the latest attempted row outcome. var failedSet by remember { mutableStateOf?>(null) } // Allow raises standing privilege strictly further than anything else in this dialog can - - // ASK-forever into unattended ALLOW for every future agent and argument set - so it gets the + // Default policy into unattended ALLOW for every future agent and argument set - so it gets the // same second-tap confirmation removing a DENY gets above, plus the risk/scope context - // McpApprovalDialog shows before its own "Always Allow" (review on #636). Deny only ever - // narrows what a tool can do, so it fires on the first tap like Reset does above. - var confirmingAllow by remember(availableTools) { mutableStateOf(null) } + // McpApprovalDialog shows before its own "Always Allow" (review on #636). Selecting either + // radio option only stages it; saving is a separate explicit action. + var pending by remember(availableTools) { mutableStateOf?>(null) } if (availableTools.isEmpty()) { Text( @@ -365,23 +371,34 @@ private fun ProactivePolicySectionContent( return } - Column(modifier = Modifier.fillMaxWidth()) { - availableTools.forEach { tool -> - ProactivePolicyRow( - tool = tool, - confirming = confirmingAllow == tool, - failureMessage = failedSet?.takeIf { it.first == tool.toolName }?.second, - onArmAllow = { confirmingAllow = tool }, - onSet = { action -> - confirmingAllow = null - scope.launch { - val outcome = onSetPolicy(tool, action) - failedSet = outcome.proactivePolicyMessage()?.let { tool.toolName to it } - if (outcome is McpProactivePolicyOutcome.Refused) onRefreshCandidates() - } - }, - colors = colors, + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + availableTools.groupBy { it.providerId }.forEach { (provider, tools) -> + Text( + text = "$provider · ${tools.size} tools", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = colors.textSecondary, + modifier = Modifier.padding(top = 8.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) + tools.forEach { tool -> + ProactivePolicyRow( + tool = tool, + selected = pending?.takeIf { it.first == tool }?.second, + failureMessage = failedSet?.takeIf { it.first == tool.toolName }?.second, + onSelect = { pending = tool to it }, + onSave = { action -> + pending = null + scope.launch { + val outcome = onSetPolicy(tool, action) + failedSet = outcome.proactivePolicyMessage()?.let { tool.toolName to it } + if (outcome is McpProactivePolicyOutcome.Refused) onRefreshCandidates() + } + }, + colors = colors, + ) + } } } } @@ -389,64 +406,78 @@ private fun ProactivePolicySectionContent( @Composable private fun ProactivePolicyRow( tool: McpToolIdentity, - confirming: Boolean, + selected: McpPolicyAction?, failureMessage: String?, - onArmAllow: () -> Unit, - onSet: (McpPolicyAction) -> Unit, + onSelect: (McpPolicyAction) -> Unit, + onSave: (McpPolicyAction) -> Unit, colors: BossColorScheme, ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 3.dp) - .background( - color = colors.textSecondary.copy(alpha = 0.04f), - shape = RoundedCornerShape(8.dp), - ).padding(horizontal = 12.dp, vertical = 8.dp), + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = colors.textSecondary.copy(alpha = 0.035f), + border = BorderStroke(1.dp, colors.textSecondary.copy(alpha = 0.14f)), ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = tool.toolName, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = colors.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = tool.providerId, - fontSize = 10.sp, - color = colors.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (failureMessage != null) { - Text( - text = failureMessage, - fontSize = 10.sp, - color = colors.alert, - ) - } + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = tool.toolName, + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + color = colors.textPrimary, + ) + Row(Modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PolicyChoice("Allow", selected == McpPolicyAction.ALLOW, { onSelect(McpPolicyAction.ALLOW) }, colors) + PolicyChoice("Deny", selected == McpPolicyAction.DENY, { onSelect(McpPolicyAction.DENY) }, colors) } - - if (confirming) { - TextButton(onClick = { onSet(McpPolicyAction.ALLOW) }) { - Text("Confirm allow?", fontSize = 11.sp, color = colors.warn) - } - } else { - TextButton(onClick = onArmAllow) { - Text("Allow", fontSize = 11.sp, color = colors.warn) - } + if (selected == McpPolicyAction.ALLOW) { + ProactiveAllowConfirmation(tool.toolName, colors) } - TextButton(onClick = { onSet(McpPolicyAction.DENY) }) { - Text("Deny", fontSize = 11.sp, color = colors.alert) + if (failureMessage != null) { + Text(failureMessage, fontSize = 12.sp, color = colors.alert) + } + selected?.let { action -> + Button( + onClick = { onSave(action) }, + colors = + ButtonDefaults.buttonColors( + backgroundColor = colors.signal, + contentColor = colors.onSignal, + ), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.align(Alignment.End), + ) { + Text(if (action == McpPolicyAction.ALLOW) "Confirm allow?" else "Save rule", fontSize = 12.sp) + } } } - if (confirming) { - ProactiveAllowConfirmation(tool.toolName, colors) - } + } +} + +@Composable +private fun PolicyChoice( + label: String, + selected: Boolean, + onClick: () -> Unit, + colors: BossColorScheme, +) { + Row( + modifier = + Modifier + .background( + if (selected) colors.signal.copy(alpha = 0.10f) else colors.textSecondary.copy(alpha = 0.04f), + RoundedCornerShape(8.dp), + ).selectable(selected = selected, onClick = onClick, role = Role.RadioButton) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RadioButton( + selected = selected, + onClick = null, + colors = RadioButtonDefaults.colors(selectedColor = colors.signal, unselectedColor = colors.textSecondary), + ) + Text(label, fontSize = 13.sp, color = colors.textPrimary) } } @@ -458,18 +489,18 @@ private fun ProactiveAllowConfirmation( ) { val risk = remember(toolName) { DefaultMcpRiskEvaluator().evaluateRisk(toolName, McpToolArgs(emptyMap())) } if (McpMutatingToolCatalog.isMutating(toolName)) { - Text("This tool performs mutations or external execution.", fontSize = 10.sp, color = colors.alert) + Text("This tool performs mutations or external execution.", fontSize = 12.sp, color = colors.alert) } Text( text = "${risk.level}: ${risk.reason}", - fontSize = 10.sp, + fontSize = 12.sp, color = colors.alert, ) Text( text = "Applies to this tool name for all agents and arguments, across restarts " + "and replacement plugins, until reset above.", - fontSize = 10.sp, + fontSize = 12.sp, color = colors.textSecondary, ) } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt index aad37f8f8f..e0359ac096 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt @@ -141,6 +141,8 @@ class McpProactivePolicyDialogTest { } rule.onNodeWithText("Allow", substring = false).performScrollTo().performClick() rule.onNodeWithText("Confirm allow?").assertExists() + rule.runOnIdle { assertEquals(0, writes) } + closeIsInsideWindow() rule.runOnIdle { candidate.value = candidate.value.copy(expectedRevocation = 1) } rule.onNodeWithText("Confirm allow?").assertDoesNotExist() rule.onNodeWithText("Allow", substring = false).performScrollTo().performClick() @@ -161,6 +163,7 @@ class McpProactivePolicyDialogTest { ) } rule.onNodeWithText("Deny", substring = false).performScrollTo().performClick() + rule.onNodeWithText("Save rule").performScrollTo().performClick() rule.onNodeWithText("Policy changed.", substring = true).assertExists() rule.runOnIdle { assertEquals(1, refreshes) } assertTrue(McpProactivePolicyOutcome.Failed("disk").proactivePolicyMessage()!!.contains("storage")) @@ -178,6 +181,7 @@ class McpProactivePolicyDialogTest { ) } rule.onNodeWithText("Deny", substring = false).performScrollTo().performClick() + rule.onNodeWithText("Save rule").performScrollTo().performClick() rule.onNodeWithText("Policy file unreadable:", substring = true).performScrollTo().assertIsDisplayed() assertTrue(McpProactivePolicyOutcome.Denied.proactivePolicyMessage()!!.contains("already denies")) } From de67e2a8c575b656d2392b7ccc656f4973c72341 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 20:58:48 -0700 Subject: [PATCH 25/49] ui: show searchable tool descriptions in MCP policies --- .../bars/horizontal/BossBottomBar.kt | 7 ++++++- .../dialogs/McpPolicyManagerDialog.kt | 15 ++++++++++--- .../dialogs/McpProactivePolicyDialogTest.kt | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt index 92919404c2..d159816773 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt @@ -440,7 +440,12 @@ internal fun mcpProactivePolicyCandidates( .filter { it.definition.name !in rules } .filter { it.definition.name !in disabledToolNames } .map { - McpToolIdentity(it.definition.name, it.providerId, revocationVersion(it.definition.name, it.providerId)) + McpToolIdentity( + it.definition.name, + it.providerId, + revocationVersion(it.definition.name, it.providerId), + it.definition.description, + ) }.sortedBy { it.toolName } .toList() diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt index 9975475da8..aa66025325 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt @@ -302,7 +302,9 @@ private fun emptyRulesMessage(noSavedRules: Boolean): String = if (noSavedRules) "No saved rules. Default policies and session trust still apply." else "No matching saved rules." private fun McpToolIdentity.matchesPolicyQuery(query: String): Boolean = - toolName.contains(query.trim(), ignoreCase = true) || providerId.contains(query.trim(), ignoreCase = true) + toolName.contains(query.trim(), ignoreCase = true) || + providerId.contains(query.trim(), ignoreCase = true) || + description.contains(query.trim(), ignoreCase = true) @Composable private fun FilteredPolicyCandidates( @@ -337,6 +339,7 @@ data class McpToolIdentity( val toolName: String, val providerId: String, val expectedRevocation: Long, + val description: String = "", ) /** @@ -418,7 +421,7 @@ private fun ProactivePolicyRow( color = colors.textSecondary.copy(alpha = 0.035f), border = BorderStroke(1.dp, colors.textSecondary.copy(alpha = 0.14f)), ) { - Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = tool.toolName, fontSize = 14.sp, @@ -426,6 +429,12 @@ private fun ProactivePolicyRow( fontWeight = FontWeight.SemiBold, color = colors.textPrimary, ) + Text( + text = tool.description.ifBlank { "No description provided by this tool." }, + fontSize = 13.sp, + lineHeight = 19.sp, + color = colors.textSecondary, + ) Row(Modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { PolicyChoice("Allow", selected == McpPolicyAction.ALLOW, { onSelect(McpPolicyAction.ALLOW) }, colors) PolicyChoice("Deny", selected == McpPolicyAction.DENY, { onSelect(McpPolicyAction.DENY) }, colors) @@ -468,7 +477,7 @@ private fun PolicyChoice( if (selected) colors.signal.copy(alpha = 0.10f) else colors.textSecondary.copy(alpha = 0.04f), RoundedCornerShape(8.dp), ).selectable(selected = selected, onClick = onClick, role = Role.RadioButton) - .padding(horizontal = 10.dp, vertical = 8.dp), + .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt index e0359ac096..30cafbbbe7 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt @@ -123,6 +123,27 @@ class McpProactivePolicyDialogTest { rule.runOnIdle { assertTrue(closed) } } + @Test fun `long descriptions remain readable without changing a policy`() { + val description = "Edit the current document using an AI instruction. ".repeat(6) + var writes = 0 + show { + McpPolicyManagerDialog( + emptyMap(), + listOf(McpToolIdentity("ai_compose", "editor-tab", 0, description)), + { true }, + { _, _ -> + writes++ + McpProactivePolicyOutcome.Saved + }, + {}, + {}, + ) + } + rule.onNodeWithText(description).performScrollTo().assertExists() + closeIsInsideWindow() + rule.runOnIdle { assertEquals(0, writes) } + } + @Test fun `replacing a candidate invalidates the armed confirmation in light theme`() { val candidate = mutableStateOf(McpToolIdentity("tool", "provider", 0)) var writes = 0 From 8791305e89415c456fab161cd09ad6172bc88d70 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 21:01:09 -0700 Subject: [PATCH 26/49] ui: describe MCP tool policies on hover --- .../components/bars/horizontal/BossBottomBar.kt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt index d159816773..8775b9d9e3 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt @@ -354,12 +354,17 @@ private fun McpPolicyManagerStatusItem(persistedPolicyConfig: McpToolPolicyConfi var showPolicyManager by remember { mutableStateOf(false) } val ruleCount = persistedPolicyConfig.rules.size if (ruleCount > 0 || allTools.isNotEmpty()) { - BossActionButton( - imageVector = Icons.Outlined.Tune, - text = if (ruleCount > 0) "Tool policies ($ruleCount)" else "Tool policies", - color = BossTheme.colors.textSecondary, - onClick = { showPolicyManager = true }, - ) + HoverTooltipBox( + text = "Manage MCP tool permissions. Review, allow, deny, or reset saved rules across agents and restarts.", + placement = TooltipPlacement.TOP, + ) { + BossActionButton( + imageVector = Icons.Outlined.Tune, + text = if (ruleCount > 0) "Tool policies ($ruleCount)" else "Tool policies", + color = BossTheme.colors.textSecondary, + onClick = { showPolicyManager = true }, + ) + } } if (showPolicyManager) { val disabledToolNames by McpToolRegistryImpl.disabledToolNames.collectAsState() From 9c78f508bd9506b0007770b7446e831076ce9333 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 21:14:53 -0700 Subject: [PATCH 27/49] feat: apply MCP tool policies by section and access mode --- AGENTS.md | 14 + .../bars/horizontal/BossBottomBar.kt | 22 +- .../bars/horizontal/McpPolicyActions.kt | 19 ++ .../dialogs/McpPolicyManagerDialog.kt | 72 ++-- .../components/dialogs/McpPolicySections.kt | 314 ++++++++++++++++++ .../boss/components/dialogs/McpSectionMode.kt | 16 + .../ai/rever/boss/mcp/McpPolicyEngine.kt | 51 +++ .../dialogs/McpProactivePolicyDialogTest.kt | 82 ++++- .../ai/rever/boss/mcp/McpSectionPolicyTest.kt | 96 ++++++ 9 files changed, 650 insertions(+), 36 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/McpPolicyActions.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionMode.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpSectionPolicyTest.kt diff --git a/AGENTS.md b/AGENTS.md index fd978cf0f5..d033000633 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2136,3 +2136,17 @@ storage failures get separate feedback. Stable DENY and damaged-file refusals ar inside the dialog, including backup/recovery guidance. Confirmation is tied to the full candidate snapshot. These privileged writes remain beside host policy enforcement. #416 tracks the separate observation/plugin architecture; this PR does not expose a policy writer to plugins. + +The MCP tool policies dialog also groups currently registered, enabled tools by +provider into sections. All allows the section, View selects tools declared +read-only except names in the host mutating catalog, Update selects the remaining +tools, and Custom uses individual checkboxes. Applying a preset denies tools +outside its selection; existing tool rules are replaced only after the operator +confirms the displayed counts and scope. These are explicit tool-name rules, +not provider trust: future tools are not automatically granted access. +`McpPolicyEngine.setSectionPolicies` writes the reviewed section atomically, +checks every prior rule and tool/provider revocation stamp, refuses provider DENY +and unreadable policy files, and invalidates queued grants/session trust after a +successful save. Keep these checks when changing section UI; sequential calls to +`setToolPolicy` would permit partial application and stale overwrites. Individual +reset controls remain available below the sections. diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt index 8775b9d9e3..ab701ae7b6 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt @@ -399,18 +399,19 @@ private fun McpPolicyManagerStatusItem(persistedPolicyConfig: McpToolPolicyConfi // clobber it (review on #636). tool.expectedRevocation is still passed, and still // checked first, to catch a DENY or provider-wide reset the same way the reactive // path's own capture-then-recheck does. - onSetPolicy = { tool, action -> - withContext(Dispatchers.IO) { - McpToolRegistryImpl.policyEngine.setToolPolicyIfAbsent( - tool.toolName, - action, - expectedRevocation = tool.expectedRevocation, - providerId = tool.providerId, - ) - } - }, + onSetPolicy = ::saveProactiveToolPolicy, onRefreshCandidates = { candidateRefresh++ }, onDismiss = { showPolicyManager = false }, + sectionTools = + mcpProactivePolicyCandidates( + allTools, + emptyMap(), + disabledToolNames, + McpToolRegistryImpl.policyEngine::revocationVersion, + ), + onApplySection = { changes -> + withContext(Dispatchers.IO) { McpToolRegistryImpl.policyEngine.setSectionPolicies(changes) } + }, ) } } @@ -450,6 +451,7 @@ internal fun mcpProactivePolicyCandidates( it.providerId, revocationVersion(it.definition.name, it.providerId), it.definition.description, + it.definition.readOnly, ) }.sortedBy { it.toolName } .toList() diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/McpPolicyActions.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/McpPolicyActions.kt new file mode 100644 index 0000000000..f700300337 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/McpPolicyActions.kt @@ -0,0 +1,19 @@ +package ai.rever.boss.components.bars.horizontal + +import ai.rever.boss.components.dialogs.McpToolIdentity +import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.mcp.McpToolRegistryImpl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal suspend fun saveProactiveToolPolicy( + tool: McpToolIdentity, + action: McpPolicyAction, +) = withContext(Dispatchers.IO) { + McpToolRegistryImpl.policyEngine.setToolPolicyIfAbsent( + tool.toolName, + action, + expectedRevocation = tool.expectedRevocation, + providerId = tool.providerId, + ) +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt index aa66025325..07c757add3 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt @@ -3,6 +3,7 @@ package ai.rever.boss.components.dialogs import ai.rever.boss.mcp.McpMutatingToolCatalog import ai.rever.boss.mcp.McpPolicyAction import ai.rever.boss.mcp.McpProactivePolicyOutcome +import ai.rever.boss.mcp.McpSectionPolicyChange import ai.rever.boss.mcp.sandbox.DefaultMcpRiskEvaluator import ai.rever.boss.plugin.api.McpToolArgs import ai.rever.boss.plugin.ui.BossColorScheme @@ -89,6 +90,8 @@ fun McpPolicyManagerDialog( onSetPolicy: suspend (tool: McpToolIdentity, action: McpPolicyAction) -> McpProactivePolicyOutcome, onRefreshCandidates: () -> Unit, onDismiss: () -> Unit, + sectionTools: List? = null, + onApplySection: (suspend (List) -> McpProactivePolicyOutcome)? = null, ) { val windowSize = LocalWindowInfo.current.containerSize val windowHeight = with(LocalDensity.current) { windowSize.height.toDp() } @@ -160,6 +163,31 @@ fun McpPolicyManagerDialog( ), ) Spacer(modifier = Modifier.height(20.dp)) + Text( + text = policyToolsHeading(sectionTools, availableTools), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = colors.textPrimary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = policyToolsDescription(sectionTools), + fontSize = 11.sp, + color = colors.textSecondary, + ) + Spacer(modifier = Modifier.height(8.dp)) + PolicyToolContent( + sectionTools, + rules, + query, + onApplySection, + onRefreshCandidates, + filteredTools, + availableTools.isNotEmpty(), + onSetPolicy, + colors, + ) + Spacer(modifier = Modifier.height(20.dp)) Text( text = "Saved rules · ${rules.size}", fontSize = 13.sp, @@ -256,30 +284,6 @@ fun McpPolicyManagerDialog( } } } - - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = "Available tools · ${availableTools.size}", - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - color = colors.textPrimary, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = - "Choose Allow or Deny for a tool without a saved rule. " + - "Rules follow the tool name, including replacement plugins.", - fontSize = 11.sp, - color = colors.textSecondary, - ) - Spacer(modifier = Modifier.height(8.dp)) - FilteredPolicyCandidates( - filteredTools, - availableTools.isNotEmpty(), - onSetPolicy, - onRefreshCandidates, - colors, - ) } } Spacer(modifier = Modifier.height(16.dp)) @@ -340,6 +344,7 @@ data class McpToolIdentity( val providerId: String, val expectedRevocation: Long, val description: String = "", + val readOnly: Boolean = false, ) /** @@ -537,3 +542,22 @@ internal fun McpProactivePolicyOutcome.proactivePolicyMessage(): String? = "Could not save this rule. Check the host log and storage, then try again." } } + +@Composable +private fun PolicyToolContent( + sections: List?, + rules: Map, + query: String, + onApply: (suspend (List) -> McpProactivePolicyOutcome)?, + onRefresh: () -> Unit, + filteredTools: List, + hasTools: Boolean, + onSet: suspend (McpToolIdentity, McpPolicyAction) -> McpProactivePolicyOutcome, + colors: BossColorScheme, +) { + if (sections != null && onApply != null) { + McpPolicySections(sections, rules, query, onApply, onRefresh) + } else { + FilteredPolicyCandidates(filteredTools, hasTools, onSet, onRefresh, colors) + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt new file mode 100644 index 0000000000..3cc1be2240 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt @@ -0,0 +1,314 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.mcp.McpMutatingToolCatalog +import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.mcp.McpProactivePolicyOutcome +import ai.rever.boss.mcp.McpSectionPolicyChange +import ai.rever.boss.plugin.ui.BossTheme +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.RadioButton +import androidx.compose.material.RadioButtonDefaults +import androidx.compose.material.Surface +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch + +internal fun McpToolIdentity.isViewTool(): Boolean = readOnly && !McpMutatingToolCatalog.isMutating(toolName) + +internal fun sectionSelection( + tools: List, + mode: McpSectionMode, +): Set = + tools + .filter { + when (mode) { + McpSectionMode.All -> true + McpSectionMode.View -> it.isViewTool() + McpSectionMode.Update -> !it.isViewTool() + McpSectionMode.Custom -> false + } + }.map { it.toolName } + .toSet() + +@Composable +internal fun McpPolicySections( + tools: List, + rules: Map, + query: String, + onApply: suspend (List) -> McpProactivePolicyOutcome, + onRefresh: () -> Unit, +) { + val groups = + tools.groupBy { it.providerId }.filter { (provider, members) -> + provider.contains(query.trim(), true) || + members.any { + it.toolName.contains(query.trim(), true) || it.description.contains(query.trim(), true) + } + } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Presets allow selected tools and deny the rest. " + + "Search keeps matching sections complete.", + color = BossTheme.colors.textSecondary, + fontSize = 12.sp, + ) + groups.forEach { (provider, members) -> + key(provider) { McpPolicySection(provider, members, rules, onApply, onRefresh) } + } + if (groups.isEmpty()) Text("No matching sections.", color = BossTheme.colors.textSecondary) + } +} + +@Composable +private fun McpPolicySection( + provider: String, + tools: List, + rules: Map, + onApply: suspend (List) -> McpProactivePolicyOutcome, + onRefresh: () -> Unit, +) { + val colors = BossTheme.colors + val initial = tools.filter { rules[it.toolName] == McpPolicyAction.ALLOW }.map { it.toolName }.toSet() + var selected by remember(tools, rules) { mutableStateOf(initial) } + var mode by remember(tools, rules) { mutableStateOf(savedSectionMode(tools, rules)) } + var dirty by remember(tools, rules) { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } + var saving by remember { mutableStateOf(false) } + Surface( + shape = RoundedCornerShape(10.dp), + color = colors.textSecondary.copy(alpha = 0.035f), + border = BorderStroke(1.dp, colors.textSecondary.copy(alpha = 0.14f)), + ) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionHeader(provider, tools.size, selected.size, !saving) { + selected = if (it) tools.map { tool -> tool.toolName }.toSet() else emptySet() + mode = if (it) McpSectionMode.All else McpSectionMode.Custom + dirty = true + } + SectionModes(mode, !saving) { + mode = it + if (it != McpSectionMode.Custom) selected = sectionSelection(tools, it) + expanded = it == McpSectionMode.Custom + dirty = true + } + TextButton(onClick = { expanded = !expanded }) { + Text(if (expanded) "Hide tools" else "Review tools", color = colors.signal, fontSize = 12.sp) + } + if (expanded) { + tools.forEach { tool -> + SectionToolRow(tool, tool.toolName in selected, !saving) { checked -> + selected = if (checked) selected + tool.toolName else selected - tool.toolName + mode = McpSectionMode.Custom + dirty = true + } + } + } + SectionConfirmation( + tools, + rules, + selected, + dirty, + onApply, + onRefresh, + onSaving = { saving = it }, + onDone = { dirty = false }, + ) + } + } +} + +@Composable +private fun SectionModes( + selected: McpSectionMode, + enabled: Boolean, + onSelect: (McpSectionMode) -> Unit, +) { + val colors = BossTheme.colors + Column(Modifier.selectableGroup()) { + // Two rows also fit narrow windows without clipping the labels. + McpSectionMode.entries.chunked(2).forEach { modes -> + Row(Modifier.fillMaxWidth()) { + modes.forEach { mode -> + Row( + Modifier + .weight(1f) + .selectable(selected == mode, enabled, Role.RadioButton) { onSelect(mode) } + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + RadioButton( + selected == mode, + null, + enabled = enabled, + colors = + RadioButtonDefaults.colors( + selectedColor = colors.signal, + unselectedColor = colors.textSecondary, + ), + ) + Text(mode.name, fontSize = 13.sp, color = colors.textPrimary) + } + } + } + } + } +} + +@Composable +private fun SectionToolRow( + tool: McpToolIdentity, + selected: Boolean, + enabled: Boolean, + onSelect: (Boolean) -> Unit, +) { + val colors = BossTheme.colors + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Checkbox( + selected, + onSelect, + modifier = Modifier.semantics { contentDescription = "Allow ${tool.toolName}" }, + enabled = enabled, + colors = CheckboxDefaults.colors(checkedColor = colors.signal), + ) + Column(Modifier.weight(1f).padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(tool.toolName, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.textPrimary) + Text( + if (tool.isViewTool()) "View · read-only" else "Update · changes state or executes actions", + fontSize = 11.sp, + color = colors.textSecondary, + ) + Text( + tool.description.ifBlank { "No description provided by this tool." }, + fontSize = 12.sp, + lineHeight = 18.sp, + color = colors.textSecondary, + ) + } + } +} + +@Composable +private fun SectionConfirmation( + tools: List, + rules: Map, + selected: Set, + dirty: Boolean, + onApply: suspend (List) -> McpProactivePolicyOutcome, + onRefresh: () -> Unit, + onSaving: (Boolean) -> Unit, + onDone: () -> Unit, +) { + val colors = BossTheme.colors + val scope = rememberCoroutineScope() + var saving by remember { mutableStateOf(false) } + var feedback by remember { mutableStateOf(null) } + if (dirty) { + Text( + "Save ${selected.size} Allow and ${tools.size - selected.size} Deny rules. " + + "Replaces saved rules for these tools, for all agents and arguments across restarts. " + + "Allows may run actions without prompting. Future tools are not included.", + color = colors.textSecondary, + fontSize = 12.sp, + ) + Button( + enabled = !saving, + colors = + ButtonDefaults.buttonColors( + backgroundColor = colors.signal, + contentColor = colors.onSignal, + ), + onClick = { + val changes = + tools.map { + McpSectionPolicyChange( + it.toolName, + it.providerId, + it.expectedRevocation, + rules[it.toolName], + if (it.toolName in selected) McpPolicyAction.ALLOW else McpPolicyAction.DENY, + ) + } + saving = true + onSaving(true) + scope.launch { + try { + val outcome = onApply(changes) + feedback = outcome.proactivePolicyMessage() ?: "Section saved." + onDone() + onRefresh() + } finally { + saving = false + onSaving(false) + } + } + }, + ) { Text(if (saving) "Saving…" else "Confirm section changes", fontSize = 12.sp) } + } + feedback?.let { Text(it, color = colors.textSecondary, fontSize = 12.sp) } +} + +internal fun policyToolsHeading( + sections: List?, + available: List, +): String = if (sections == null) "Available tools · ${available.size}" else "Tool sections · ${sections.size} tools" + +internal fun policyToolsDescription(sections: List?): String = + if (sections == null) { + "Choose Allow or Deny for a tool without a saved rule. Rules follow the tool name." + } else { + "Enable a section or choose All, View, Update, or Custom. Changes require confirmation." + } + +@Composable +private fun SectionHeader( + provider: String, + count: Int, + selectedCount: Int, + enabled: Boolean, + onEnable: (Boolean) -> Unit, +) { + val colors = BossTheme.colors + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(provider, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.textPrimary) + Text("$count tools · $selectedCount selected", fontSize = 12.sp, color = colors.textSecondary) + } + Switch( + modifier = Modifier.semantics { contentDescription = "Enable $provider section" }, + checked = selectedCount > 0, + onCheckedChange = onEnable, + enabled = enabled, + colors = SwitchDefaults.colors(checkedThumbColor = colors.signal), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionMode.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionMode.kt new file mode 100644 index 0000000000..e7da767899 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionMode.kt @@ -0,0 +1,16 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.mcp.McpPolicyAction + +internal enum class McpSectionMode { All, View, Update, Custom } + +internal fun savedSectionMode( + tools: List, + rules: Map, +): McpSectionMode { + val explicit = tools.all { rules[it.toolName] in setOf(McpPolicyAction.ALLOW, McpPolicyAction.DENY) } + val allowed = tools.filter { rules[it.toolName] == McpPolicyAction.ALLOW }.map { it.toolName }.toSet() + if (!explicit || allowed.isEmpty()) return McpSectionMode.Custom + return listOf(McpSectionMode.All, McpSectionMode.View, McpSectionMode.Update) + .firstOrNull { sectionSelection(tools, it) == allowed } ?: McpSectionMode.Custom +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt index c5b0b2bbec..3175326d33 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt @@ -65,6 +65,15 @@ sealed interface McpProactivePolicyOutcome { ) : McpProactivePolicyOutcome } +/** One explicitly reviewed change in a host UI section snapshot. */ +data class McpSectionPolicyChange( + val toolName: String, + val providerId: String, + val expectedRevocation: Long, + val expectedRule: McpPolicyAction?, + val action: McpPolicyAction, +) + /** * Manages MCP tool execution policies (ALLOW / ASK / DENY). * @@ -331,6 +340,48 @@ class McpPolicyEngine( ) } + /** Save a reviewed section in one durable write; concurrent edits invalidate the whole snapshot. */ + fun setSectionPolicies(changes: List): McpProactivePolicyOutcome = + synchronized(lock) { + if (_fault.value is McpPolicyFault.PersistedPolicyUnreadable) { + return@synchronized McpProactivePolicyOutcome.PolicyUnreadable + } + if (changes.isEmpty() || changes.distinctBy { it.toolName }.size != changes.size) { + return@synchronized McpProactivePolicyOutcome.Refused + } + if (changes.any { + revocationVersion(it.toolName, it.providerId) != it.expectedRevocation || + _config.value.rules[it.toolName] != it.expectedRule + } + ) { + return@synchronized McpProactivePolicyOutcome.Refused + } + if (changes.any { + it.action == McpPolicyAction.ALLOW && + _config.value.providerRules[it.providerId] == McpPolicyAction.DENY + } + ) { + return@synchronized McpProactivePolicyOutcome.Denied + } + val outcome = + writeConfig( + key = changes.first().providerId, + logKey = "section", + updated = + _config.value.copy( + rules = _config.value.rules + changes.associate { it.toolName to it.action }, + ), + successMessage = "Updated section tool policies", + failureMessage = "Failed to persist MCP section policies", + faultFor = { k, e -> McpPolicyFault.PolicyPersistFailed(k, e) }, + ) + if (outcome == McpProactivePolicyOutcome.Saved) { + changes.forEach { revocations[it.toolName] = revocationVersion(it.toolName) + 1 } + _sessionTrustedTools.update { trusted -> trusted - changes.map { it.toolName }.toSet() } + } + outcome + } + /** * Trust every tool [providerId] contributes, persistently - "Trust this plugin" in the * approval dialog, for an operator who does not want to approve each of its tools one at a diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt index 30cafbbbe7..3a24c16576 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick @@ -79,7 +80,8 @@ class McpProactivePolicyDialogTest { }, ) { val width = (if (windowSize.width > 0) windowSize.width else 700).dp - Box(Modifier.size(width, 360.dp).clipToBounds()) { content() } + val height = (if (windowSize.height > 0) windowSize.height else 360).dp + Box(Modifier.size(width, height).clipToBounds()) { content() } } } rule.mainClock.advanceTimeBy(250) @@ -91,7 +93,8 @@ class McpProactivePolicyDialogTest { if (System.getenv("BOSS_REVIEW_CAPTURE") == "1") captureLayout() rule.onNodeWithText("Close").assertIsDisplayed() val bounds = rule.onNodeWithText("Close").getUnclippedBoundsInRoot() - assertTrue(bounds.top >= 0.dp && bounds.bottom <= 360.dp, "Close bounds: $bounds") + val windowBounds = rule.onRoot().getUnclippedBoundsInRoot() + assertTrue(bounds.top >= windowBounds.top && bounds.bottom <= windowBounds.bottom, "Close bounds: $bounds") } private fun captureLayout() { @@ -123,6 +126,81 @@ class McpProactivePolicyDialogTest { rule.runOnIdle { assertTrue(closed) } } + @Test fun `custom selection works in a short light window`() { + val tools = listOf(McpToolIdentity("read", "provider", 0, "Read the current document", readOnly = true)) + var saved = emptyList() + show(light = true) { + McpPolicyManagerDialog( + emptyMap(), + tools, + { true }, + { _, _ -> McpProactivePolicyOutcome.Saved }, + {}, + {}, + sectionTools = tools, + onApplySection = { + saved = it + McpProactivePolicyOutcome.Saved + }, + ) + } + rule.onNodeWithText("Custom", substring = false).performScrollTo().performClick() + rule.onNodeWithContentDescription("Allow read").performScrollTo().performClick() + rule.runOnIdle { assertTrue(saved.isEmpty()) } + captureTheme = "sections-light" + closeIsInsideWindow() + rule.onNodeWithText("Confirm section changes").performScrollTo().performClick() + rule.runOnIdle { assertEquals(McpPolicyAction.ALLOW, saved.single().action) } + } + + @Test fun `all and update presets cover the entire section and saved mode is restored`() { + val tools = + listOf( + McpToolIdentity("read", "p", 0, readOnly = true), + McpToolIdentity("write", "p", 0, readOnly = false), + ) + assertEquals(setOf("read", "write"), sectionSelection(tools, McpSectionMode.All)) + assertEquals(setOf("write"), sectionSelection(tools, McpSectionMode.Update)) + assertEquals(McpSectionMode.Custom, savedSectionMode(tools, emptyMap())) + assertEquals( + McpSectionMode.View, + savedSectionMode(tools, mapOf("read" to McpPolicyAction.ALLOW, "write" to McpPolicyAction.DENY)), + ) + } + + @Test fun `view section preset stages all rules and excludes known mutations`() { + val tools = + listOf( + McpToolIdentity("read", "provider", 0, "Read current state", readOnly = true), + McpToolIdentity("k8s_delete", "provider", 0, "Delete a workload", readOnly = true), + ) + var saved = emptyList() + show(windowSize = IntSize(700, 800)) { + McpPolicyManagerDialog( + emptyMap(), + tools, + { true }, + { _, _ -> McpProactivePolicyOutcome.Saved }, + {}, + {}, + sectionTools = tools, + onApplySection = { + saved = it + McpProactivePolicyOutcome.Saved + }, + ) + } + rule.onNodeWithText("View", substring = false).performScrollTo().performClick() + rule.runOnIdle { assertTrue(saved.isEmpty()) } + captureTheme = "sections-dark" + closeIsInsideWindow() + rule.onNodeWithText("Confirm section changes").performScrollTo().performClick() + rule.runOnIdle { + assertEquals(listOf(McpPolicyAction.ALLOW, McpPolicyAction.DENY), saved.map { it.action }) + } + closeIsInsideWindow() + } + @Test fun `long descriptions remain readable without changing a policy`() { val description = "Edit the current document using an AI instruction. ".repeat(6) var writes = 0 diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpSectionPolicyTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpSectionPolicyTest.kt new file mode 100644 index 0000000000..ddc715bf5e --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpSectionPolicyTest.kt @@ -0,0 +1,96 @@ +package ai.rever.boss.mcp + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class McpSectionPolicyTest { + private fun changes(engine: McpPolicyEngine) = + listOf("read", "write").map { + McpSectionPolicyChange( + it, + "plugin", + engine.revocationVersion(it, "plugin"), + engine.config.value.rules[it], + McpPolicyAction.ALLOW, + ) + } + + @Test fun `section saves every rule and invalidates previous queued grants`() { + val engine = McpPolicyEngine() + engine.setToolPolicy("write", McpPolicyAction.DENY) + val snapshot = changes(engine) + assertIs(engine.setSectionPolicies(snapshot)) + assertEquals( + mapOf("read" to McpPolicyAction.ALLOW, "write" to McpPolicyAction.ALLOW), + engine.config.value.rules, + ) + assertTrue(engine.revocationVersion("write", "plugin") > snapshot.last().expectedRevocation) + } + + @Test fun `intervening rule change refuses entire section`() { + val engine = McpPolicyEngine() + val snapshot = changes(engine) + engine.setToolPolicy("write", McpPolicyAction.ASK) + assertIs(engine.setSectionPolicies(snapshot)) + assertEquals(mapOf("write" to McpPolicyAction.ASK), engine.config.value.rules) + } + + @Test fun `provider denial blocks all section grants`() { + val engine = McpPolicyEngine() + val snapshot = changes(engine) + engine.setProviderPolicy("plugin", McpPolicyAction.DENY) + assertIs(engine.setSectionPolicies(snapshot)) + assertTrue( + engine.config.value.rules + .isEmpty(), + ) + } + + @Test fun `reset invalidates section even when rules return to the same value`() { + val engine = McpPolicyEngine() + val snapshot = changes(engine) + engine.revokePersistedPolicy("write") + assertIs(engine.setSectionPolicies(snapshot)) + assertTrue( + engine.config.value.rules + .isEmpty(), + ) + } + + @Test fun `failed disk write leaves every rule unchanged`() { + val dir = + kotlin.io.path + .createTempDirectory("section-policy") + .toFile() + try { + val file = java.io.File(dir, "policy.json") + val engine = McpPolicyEngine(file) + engine.setToolPolicy("read", McpPolicyAction.DENY) + val snapshot = changes(engine) + file.delete() + file.mkdir() + java.io.File(file, "blocker").writeText("occupied") + assertIs(engine.setSectionPolicies(snapshot)) + assertEquals(mapOf("read" to McpPolicyAction.DENY), engine.config.value.rules) + } finally { + dir.deleteRecursively() + } + } + + @Test fun `unreadable policy is never overwritten by a section`() { + val dir = + kotlin.io.path + .createTempDirectory("section-policy") + .toFile() + try { + val file = java.io.File(dir, "policy.json").apply { writeText("broken") } + val engine = McpPolicyEngine(file) + assertIs(engine.setSectionPolicies(changes(engine))) + assertEquals("broken", file.readText()) + } finally { + dir.deleteRecursively() + } + } +} From 2d4e577acb008722366bcfb875e4618f2e2bf1b0 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 21:23:21 -0700 Subject: [PATCH 28/49] ui: add global MCP presets and friendly plugin section names --- .../dialogs/McpGlobalPolicyControls.kt | 118 ++++++++++++++++++ .../dialogs/McpPolicyPluginNames.kt | 25 ++++ .../components/dialogs/McpPolicySections.kt | 16 ++- .../dialogs/McpProactivePolicyDialogTest.kt | 71 ++++++++++- 4 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpGlobalPolicyControls.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyPluginNames.kt diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpGlobalPolicyControls.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpGlobalPolicyControls.kt new file mode 100644 index 0000000000..4b2a14932b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpGlobalPolicyControls.kt @@ -0,0 +1,118 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.mcp.McpProactivePolicyOutcome +import ai.rever.boss.mcp.McpSectionPolicyChange +import ai.rever.boss.plugin.ui.BossTheme +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.RadioButton +import androidx.compose.material.RadioButtonDefaults +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +internal fun McpGlobalPolicyControls( + tools: List, + rules: Map, + onApply: suspend (List) -> McpProactivePolicyOutcome, + onRefresh: () -> Unit, +) { + val colors = BossTheme.colors + var selection by remember(tools, rules) { mutableStateOf(null) } + var saving by remember { mutableStateOf(false) } + val presets = + listOf( + "All" to McpSectionMode.All, + "View" to McpSectionMode.View, + "Edit" to McpSectionMode.Update, + "None" to McpSectionMode.Custom, + ) + Surface( + color = colors.textSecondary.copy(alpha = 0.04f), + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, colors.textSecondary.copy(alpha = 0.18f)), + ) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("All sections", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.textPrimary) + Text( + "Applies to all ${tools.size} tools, including sections hidden by search. " + + "View allows read-only tools; Edit allows tools that change state; None denies all tools.", + fontSize = 12.sp, + color = colors.textSecondary, + ) + Column(Modifier.selectableGroup()) { + presets.chunked(2).forEach { row -> + Row(Modifier.fillMaxWidth()) { + row.forEach { (label, mode) -> + GlobalPreset( + label, + selection == mode, + !saving && tools.isNotEmpty(), + Modifier.weight(1f), + ) { selection = mode } + } + } + } + } + SectionConfirmation( + tools, + rules, + selection?.let { sectionSelection(tools, it) }.orEmpty(), + selection != null, + onApply, + onRefresh, + onSaving = { saving = it }, + onDone = { selection = null }, + confirmLabel = "Confirm all sections", + ) + } + } +} + +@Composable +private fun GlobalPreset( + label: String, + selected: Boolean, + enabled: Boolean, + modifier: Modifier, + onSelect: () -> Unit, +) { + val colors = BossTheme.colors + Row( + modifier + .selectable(selected, enabled, Role.RadioButton, onClick = onSelect) + .semantics { contentDescription = "$label for all sections" } + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + RadioButton( + selected, + null, + enabled = enabled, + colors = RadioButtonDefaults.colors(selectedColor = colors.signal, unselectedColor = colors.textSecondary), + ) + Text(label, fontSize = 13.sp, color = colors.textPrimary) + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyPluginNames.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyPluginNames.kt new file mode 100644 index 0000000000..1d5114413c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyPluginNames.kt @@ -0,0 +1,25 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.components.home.LocalPluginStates +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember + +@Composable +internal fun mcpPolicyPluginNames(): Map { + val states by LocalPluginStates.current?.collectAsState() ?: remember { mutableStateOf(emptyMap()) } + return states.mapValues { it.value.manifest.displayName } +} + +internal fun policySectionName( + providerId: String, + names: Map, +): String = + names[providerId]?.takeIf { it.isNotBlank() } + ?: providerId + .substringAfterLast('.') + .replace('-', ' ') + .replace('_', ' ') + .replaceFirstChar { it.titlecase() } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt index 3cc1be2240..49a4068e0b 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt @@ -67,9 +67,11 @@ internal fun McpPolicySections( onApply: suspend (List) -> McpProactivePolicyOutcome, onRefresh: () -> Unit, ) { + val pluginNames = mcpPolicyPluginNames() val groups = tools.groupBy { it.providerId }.filter { (provider, members) -> - provider.contains(query.trim(), true) || + policySectionName(provider, pluginNames).contains(query.trim(), true) || + provider.contains(query.trim(), true) || members.any { it.toolName.contains(query.trim(), true) || it.description.contains(query.trim(), true) } @@ -81,8 +83,11 @@ internal fun McpPolicySections( color = BossTheme.colors.textSecondary, fontSize = 12.sp, ) + McpGlobalPolicyControls(tools, rules, onApply, onRefresh) groups.forEach { (provider, members) -> - key(provider) { McpPolicySection(provider, members, rules, onApply, onRefresh) } + key(provider) { + McpPolicySection(policySectionName(provider, pluginNames), members, rules, onApply, onRefresh) + } } if (groups.isEmpty()) Text("No matching sections.", color = BossTheme.colors.textSecondary) } @@ -218,7 +223,7 @@ private fun SectionToolRow( } @Composable -private fun SectionConfirmation( +internal fun SectionConfirmation( tools: List, rules: Map, selected: Set, @@ -227,6 +232,7 @@ private fun SectionConfirmation( onRefresh: () -> Unit, onSaving: (Boolean) -> Unit, onDone: () -> Unit, + confirmLabel: String = "Confirm section changes", ) { val colors = BossTheme.colors val scope = rememberCoroutineScope() @@ -263,7 +269,7 @@ private fun SectionConfirmation( scope.launch { try { val outcome = onApply(changes) - feedback = outcome.proactivePolicyMessage() ?: "Section saved." + feedback = outcome.proactivePolicyMessage() ?: "Policies saved." onDone() onRefresh() } finally { @@ -272,7 +278,7 @@ private fun SectionConfirmation( } } }, - ) { Text(if (saving) "Saving…" else "Confirm section changes", fontSize = 12.sp) } + ) { Text(if (saving) "Saving…" else confirmLabel, fontSize = 12.sp) } } feedback?.let { Text(it, color = colors.textSecondary, fontSize = 12.sp) } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt index 3a24c16576..477f30477a 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt @@ -1,8 +1,12 @@ package ai.rever.boss.components.dialogs +import ai.rever.boss.components.home.LocalPluginStates import ai.rever.boss.components.overlays.resetOverlayFieldForTest +import ai.rever.boss.components.plugin.DynamicPluginInfo import ai.rever.boss.mcp.McpPolicyAction import ai.rever.boss.mcp.McpProactivePolicyOutcome +import ai.rever.boss.plugin.api.PluginManifest +import ai.rever.boss.plugin.api.PluginState import ai.rever.boss.plugin.ui.BossBlueprintColorScheme import ai.rever.boss.plugin.ui.BossBlueprintLightColorScheme import ai.rever.boss.plugin.ui.BossOverlayHost @@ -20,18 +24,23 @@ import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After import org.junit.Before import org.junit.Rule @@ -126,6 +135,62 @@ class McpProactivePolicyDialogTest { rule.runOnIdle { assertTrue(closed) } } + @Test fun `global none includes sections hidden by search and waits for confirmation`() { + val tools = + listOf( + McpToolIdentity("read", "alpha", 0, readOnly = true), + McpToolIdentity("write", "beta", 0), + ) + var saved = emptyList() + val plugins = + MutableStateFlow( + mapOf( + "alpha" to + DynamicPluginInfo( + PluginManifest( + pluginId = "alpha", + displayName = "Documents", + version = "1.0.0", + mainClass = "alpha.Main", + apiVersion = "1.0.0", + ), + "/plugins/alpha.jar", + PluginState.LOADED, + 0L, + true, + ), + ), + ) + show(windowSize = IntSize(700, 800)) { + CompositionLocalProvider(LocalPluginStates provides plugins) { + McpPolicyManagerDialog( + emptyMap(), + tools, + { true }, + { _, _ -> McpProactivePolicyOutcome.Saved }, + {}, + {}, + sectionTools = tools, + onApplySection = { + saved = it + McpProactivePolicyOutcome.Saved + }, + ) + } + } + rule.onNodeWithText("Find a tool or provider").performTextInput("Documents") + rule.onAllNodesWithText("Documents", substring = false).assertCountEquals(2) + rule.onNodeWithContentDescription("None for all sections").performScrollTo().performClick() + rule.runOnIdle { assertTrue(saved.isEmpty()) } + captureTheme = "global-dark" + closeIsInsideWindow() + rule.onNodeWithText("Confirm all sections").performScrollTo().performClick() + rule.runOnIdle { + assertEquals(setOf("alpha", "beta"), saved.map { it.providerId }.toSet()) + assertTrue(saved.all { it.action == McpPolicyAction.DENY }) + } + } + @Test fun `custom selection works in a short light window`() { val tools = listOf(McpToolIdentity("read", "provider", 0, "Read the current document", readOnly = true)) var saved = emptyList() @@ -190,7 +255,11 @@ class McpProactivePolicyDialogTest { }, ) } - rule.onNodeWithText("View", substring = false).performScrollTo().performClick() + rule + .onAllNodesWithText("View", substring = false) + .onLast() + .performScrollTo() + .performClick() rule.runOnIdle { assertTrue(saved.isEmpty()) } captureTheme = "sections-dark" closeIsInsideWindow() From 312eb1c809c619f9ab94f818ff1442b11d65a83f Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 21:43:24 -0700 Subject: [PATCH 29/49] fix: address integration review of MCP policies and input handling --- AGENTS.md | 11 +- .../ai/rever/boss/app/BossAppDialogs.kt | 5 +- .../bars/horizontal/BossBottomBar.kt | 14 +- .../dialogs/McpGlobalPolicyControls.kt | 6 +- .../components/dialogs/McpPolicyFiltering.kt | 20 +++ .../dialogs/McpPolicyManagerDialog.kt | 2 +- .../dialogs/McpPolicyPluginNames.kt | 3 +- .../boss/components/dialogs/McpPolicyRisk.kt | 74 ++++++++++ .../components/dialogs/McpPolicySections.kt | 127 +++++------------- .../dialogs/McpSectionConfirmation.kt | 98 ++++++++++++++ .../boss/components/dialogs/McpSectionMode.kt | 13 +- .../ai/rever/boss/mcp/McpPolicyEngine.kt | 2 +- .../search/RememberSpotlightFileIndexer.kt | 19 +++ .../kernel/services/RunConfigServiceBridge.kt | 4 + .../plugin/browser/BrowserSwipeNavScript.kt | 6 +- .../resources/browser/swipe-nav.js | 30 ++++- .../dialogs/McpProactivePolicyDialogTest.kt | 54 +++++++- docs/review/pr650-followup.md | 17 +++ .../plugin/ipc/RunConfigDataProviderProxy.kt | 9 +- scripts/test/test-swipe-nav.js | 9 ++ 20 files changed, 406 insertions(+), 117 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyFiltering.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyRisk.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionConfirmation.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/search/RememberSpotlightFileIndexer.kt diff --git a/AGENTS.md b/AGENTS.md index d033000633..dbfdc1013e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2139,7 +2139,7 @@ observation/plugin architecture; this PR does not expose a policy writer to plug The MCP tool policies dialog also groups currently registered, enabled tools by provider into sections. All allows the section, View selects tools declared -read-only except names in the host mutating catalog, Update selects the remaining +read-only except names in the host mutating catalog and HIGH/CRITICAL risk tools, Edit selects the remaining tools, and Custom uses individual checkboxes. Applying a preset denies tools outside its selection; existing tool rules are replaced only after the operator confirms the displayed counts and scope. These are explicit tool-name rules, @@ -2150,3 +2150,12 @@ and unreadable policy files, and invalidates queued grants/session trust after a successful save. Keep these checks when changing section UI; sequential calls to `setToolPolicy` would permit partial application and stale overwrites. Individual reset controls remain available below the sections. + +The section/global confirmation UI uses the same default-risk evaluator as the +engine. HIGH/CRITICAL grants and replacements of an existing DENY display each +tool's risk and require Review followed by Confirm. Failed writes retain the +staged choices and retry action; successful or stale writes refresh revocation +snapshots. Current saved rules (including ASK/default) are visible in expanded +rows, and the summary distinguishes rules being replaced from new denials. +Global None is a distinct deny-all preset, not Custom; Edit is the label at both +levels. Search by plugin display name also matches its saved tool rules. diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt index e16b553ca6..db37d080ee 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt @@ -70,6 +70,7 @@ import ai.rever.boss.run.RunConfigurationManager import ai.rever.boss.run.RunExecutionService import ai.rever.boss.search.SearchSources import ai.rever.boss.search.ToolSearchRecord +import ai.rever.boss.search.rememberSpotlightFileIndexer import ai.rever.boss.services.auth.UserDataStorage import ai.rever.boss.services.bookmarks.BookmarkAPIAccess import ai.rever.boss.settings.MICROKERNEL_MODE_CONFIRMATION_MESSAGE @@ -109,7 +110,7 @@ internal fun BossAppDialogs(state: BossAppState) { val splitViewState = state.splitViewState val windowProjectState = state.windowProjectState val selectedProject by windowProjectState.selectedProject.collectAsState() - val spotlightFileIndexer = state.spotlightFileIndexes.indexerFor(selectedProject.path) + val spotlightFileIndexer = rememberSpotlightFileIndexer(state.spotlightFileIndexes, selectedProject.path) // Keymap settings (used by ShortcutHelpDialog) val keymapSettings by KeymapSettingsManager.currentSettings.collectAsState() @@ -441,7 +442,7 @@ internal fun BossAppDialogs(state: BossAppState) { ) } - if (state.showGlobalSearchDialog) { + if (state.showGlobalSearchDialog && spotlightFileIndexer != null) { // Offer THIS window's tools to the search, for exactly as long as its dialog is open. // // Registered here rather than per window, because the window that matters is the one whose diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt index ab701ae7b6..9454b8a240 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt @@ -403,12 +403,14 @@ private fun McpPolicyManagerStatusItem(persistedPolicyConfig: McpToolPolicyConfi onRefreshCandidates = { candidateRefresh++ }, onDismiss = { showPolicyManager = false }, sectionTools = - mcpProactivePolicyCandidates( - allTools, - emptyMap(), - disabledToolNames, - McpToolRegistryImpl.policyEngine::revocationVersion, - ), + remember(allTools, persistedPolicyConfig.rules, disabledToolNames, candidateRefresh) { + mcpProactivePolicyCandidates( + allTools, + emptyMap(), + disabledToolNames, + McpToolRegistryImpl.policyEngine::revocationVersion, + ) + }, onApplySection = { changes -> withContext(Dispatchers.IO) { McpToolRegistryImpl.policyEngine.setSectionPolicies(changes) } }, diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpGlobalPolicyControls.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpGlobalPolicyControls.kt index 4b2a14932b..f35e5cc0ea 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpGlobalPolicyControls.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpGlobalPolicyControls.kt @@ -45,8 +45,8 @@ internal fun McpGlobalPolicyControls( listOf( "All" to McpSectionMode.All, "View" to McpSectionMode.View, - "Edit" to McpSectionMode.Update, - "None" to McpSectionMode.Custom, + "Edit" to McpSectionMode.Edit, + "None" to McpSectionMode.None, ) Surface( color = colors.textSecondary.copy(alpha = 0.04f), @@ -57,7 +57,7 @@ internal fun McpGlobalPolicyControls( Text("All sections", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.textPrimary) Text( "Applies to all ${tools.size} tools, including sections hidden by search. " + - "View allows read-only tools; Edit allows tools that change state; None denies all tools.", + "View allows read-only tools; Edit allows actions and sensitive tools; None denies all tools.", fontSize = 12.sp, color = colors.textSecondary, ) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyFiltering.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyFiltering.kt new file mode 100644 index 0000000000..095a4955f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyFiltering.kt @@ -0,0 +1,20 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.mcp.McpPolicyAction + +internal fun filterSavedPolicies( + rules: Map, + tools: List?, + query: String, + pluginNames: Map, + fallbackTools: List = emptyList(), +): Map { + val byName = (tools ?: fallbackTools).associateBy { it.toolName } + val term = query.trim() + return rules.filterKeys { name -> + name.contains(term, true) || byName[name]?.let { + it.description.contains(term, true) || it.providerId.contains(term, true) || + policySectionName(it.providerId, pluginNames).contains(term, true) + } == true + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt index 07c757add3..703032e48e 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyManagerDialog.kt @@ -112,7 +112,7 @@ fun McpPolicyManagerDialog( // row's button, or dismissing, drops any pending confirmation rather than carrying it silently. var confirmingDeny by remember { mutableStateOf(null) } var query by remember { mutableStateOf("") } - val filteredRules = rules.filterKeys { it.contains(query.trim(), ignoreCase = true) } + val filteredRules = filterSavedPolicies(rules, sectionTools, query, mcpPolicyPluginNames(), availableTools) val filteredTools = availableTools.filter { it.matchesPolicyQuery(query) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyPluginNames.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyPluginNames.kt index 1d5114413c..9371bfe98a 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyPluginNames.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyPluginNames.kt @@ -9,7 +9,8 @@ import androidx.compose.runtime.remember @Composable internal fun mcpPolicyPluginNames(): Map { - val states by LocalPluginStates.current?.collectAsState() ?: remember { mutableStateOf(emptyMap()) } + val flow = LocalPluginStates.current + val states = if (flow == null) emptyMap() else flow.collectAsState().value return states.mapValues { it.value.manifest.displayName } } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyRisk.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyRisk.kt new file mode 100644 index 0000000000..e76ab6fdca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicyRisk.kt @@ -0,0 +1,74 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.mcp.sandbox.DefaultMcpRiskEvaluator +import ai.rever.boss.mcp.sandbox.McpRiskLevel +import ai.rever.boss.plugin.api.McpToolArgs +import ai.rever.boss.plugin.ui.BossTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.sp + +internal fun policyRisk(tool: McpToolIdentity) = + DefaultMcpRiskEvaluator().evaluateRisk( + tool.toolName, + McpToolArgs(emptyMap()), + ) + +internal fun sensitiveAllows( + tools: List, + selected: Set, + rules: Map, +) = tools.filter { + it.toolName in selected && + (policyRisk(it).level >= McpRiskLevel.HIGH || rules[it.toolName] == McpPolicyAction.DENY) +} + +internal fun savedPolicyLabel(rule: McpPolicyAction?): String = + when (rule) { + null -> "No saved rule · default policy and session trust apply" + McpPolicyAction.ASK -> "Saved: Ask before running" + McpPolicyAction.ALLOW -> "Saved: Allow" + McpPolicyAction.DENY -> "Saved: Deny" + } + +@Composable +internal fun PolicyChangeSummary( + tools: List, + rules: Map, + selected: Set, +) { + val changed = + tools.filter { + rules[it.toolName] != if (it.toolName in selected) McpPolicyAction.ALLOW else McpPolicyAction.DENY + } + val replaced = changed.mapNotNull { rules[it.toolName] } + val defaultDenials = changed.count { it.toolName !in rules && it.toolName !in selected } + Text( + "Save ${selected.size} Allow and ${tools.size - selected.size} Deny rules. " + + "Replaces ${replaced.size} saved rules (${replaced.count { it == McpPolicyAction.DENY }} Deny, " + + "${replaced.count { it == McpPolicyAction.ASK }} Ask). " + + "$defaultDenials tools using defaults will be denied. " + + "Applies to all agents and arguments across restarts. Future tools are not included.", + color = BossTheme.colors.textSecondary, + fontSize = 12.sp, + ) +} + +@Composable +internal fun PolicyAllowRisks( + tools: List, + rules: Map, +) { + tools.forEach { tool -> + val risk = policyRisk(tool) + Text("${tool.toolName} · ${risk.level}: ${risk.reason}", color = BossTheme.colors.alert, fontSize = 12.sp) + if (rules[tool.toolName] == McpPolicyAction.DENY) { + Text( + "Replaces an existing denial with unattended access.", + color = BossTheme.colors.alert, + fontSize = 12.sp, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt index 49a4068e0b..96da84082f 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt @@ -4,6 +4,7 @@ import ai.rever.boss.mcp.McpMutatingToolCatalog import ai.rever.boss.mcp.McpPolicyAction import ai.rever.boss.mcp.McpProactivePolicyOutcome import ai.rever.boss.mcp.McpSectionPolicyChange +import ai.rever.boss.mcp.sandbox.McpRiskLevel import ai.rever.boss.plugin.ui.BossTheme import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement @@ -42,7 +43,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch -internal fun McpToolIdentity.isViewTool(): Boolean = readOnly && !McpMutatingToolCatalog.isMutating(toolName) +internal fun McpToolIdentity.isViewTool(): Boolean = + readOnly && !McpMutatingToolCatalog.isMutating(toolName) && policyRisk(this).level < McpRiskLevel.HIGH internal fun sectionSelection( tools: List, @@ -53,8 +55,8 @@ internal fun sectionSelection( when (mode) { McpSectionMode.All -> true McpSectionMode.View -> it.isViewTool() - McpSectionMode.Update -> !it.isViewTool() - McpSectionMode.Custom -> false + McpSectionMode.Edit -> !it.isViewTool() + McpSectionMode.Custom, McpSectionMode.None -> false } }.map { it.toolName } .toSet() @@ -116,7 +118,7 @@ private fun McpPolicySection( Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { SectionHeader(provider, tools.size, selected.size, !saving) { selected = if (it) tools.map { tool -> tool.toolName }.toSet() else emptySet() - mode = if (it) McpSectionMode.All else McpSectionMode.Custom + mode = if (it) McpSectionMode.All else McpSectionMode.None dirty = true } SectionModes(mode, !saving) { @@ -130,7 +132,7 @@ private fun McpPolicySection( } if (expanded) { tools.forEach { tool -> - SectionToolRow(tool, tool.toolName in selected, !saving) { checked -> + SectionToolRow(tool, rules[tool.toolName], tool.toolName in selected, !saving) { checked -> selected = if (checked) selected + tool.toolName else selected - tool.toolName mode = McpSectionMode.Custom dirty = true @@ -160,38 +162,41 @@ private fun SectionModes( val colors = BossTheme.colors Column(Modifier.selectableGroup()) { // Two rows also fit narrow windows without clipping the labels. - McpSectionMode.entries.chunked(2).forEach { modes -> - Row(Modifier.fillMaxWidth()) { - modes.forEach { mode -> - Row( - Modifier - .weight(1f) - .selectable(selected == mode, enabled, Role.RadioButton) { onSelect(mode) } - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - RadioButton( - selected == mode, - null, - enabled = enabled, - colors = - RadioButtonDefaults.colors( - selectedColor = colors.signal, - unselectedColor = colors.textSecondary, - ), - ) - Text(mode.name, fontSize = 13.sp, color = colors.textPrimary) + listOf(McpSectionMode.All, McpSectionMode.View, McpSectionMode.Edit, McpSectionMode.Custom) + .chunked(2) + .forEach { modes -> + Row(Modifier.fillMaxWidth()) { + modes.forEach { mode -> + Row( + Modifier + .weight(1f) + .selectable(selected == mode, enabled, Role.RadioButton) { onSelect(mode) } + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + RadioButton( + selected == mode, + null, + enabled = enabled, + colors = + RadioButtonDefaults.colors( + selectedColor = colors.signal, + unselectedColor = colors.textSecondary, + ), + ) + Text(mode.name, fontSize = 13.sp, color = colors.textPrimary) + } } } } - } } } @Composable private fun SectionToolRow( tool: McpToolIdentity, + currentRule: McpPolicyAction?, selected: Boolean, enabled: Boolean, onSelect: (Boolean) -> Unit, @@ -207,8 +212,9 @@ private fun SectionToolRow( ) Column(Modifier.weight(1f).padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(tool.toolName, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.textPrimary) + Text(savedPolicyLabel(currentRule), fontSize = 12.sp, color = colors.textSecondary) Text( - if (tool.isViewTool()) "View · read-only" else "Update · changes state or executes actions", + if (tool.isViewTool()) "View · read-only" else "Edit · actions or sensitive access", fontSize = 11.sp, color = colors.textSecondary, ) @@ -222,67 +228,6 @@ private fun SectionToolRow( } } -@Composable -internal fun SectionConfirmation( - tools: List, - rules: Map, - selected: Set, - dirty: Boolean, - onApply: suspend (List) -> McpProactivePolicyOutcome, - onRefresh: () -> Unit, - onSaving: (Boolean) -> Unit, - onDone: () -> Unit, - confirmLabel: String = "Confirm section changes", -) { - val colors = BossTheme.colors - val scope = rememberCoroutineScope() - var saving by remember { mutableStateOf(false) } - var feedback by remember { mutableStateOf(null) } - if (dirty) { - Text( - "Save ${selected.size} Allow and ${tools.size - selected.size} Deny rules. " + - "Replaces saved rules for these tools, for all agents and arguments across restarts. " + - "Allows may run actions without prompting. Future tools are not included.", - color = colors.textSecondary, - fontSize = 12.sp, - ) - Button( - enabled = !saving, - colors = - ButtonDefaults.buttonColors( - backgroundColor = colors.signal, - contentColor = colors.onSignal, - ), - onClick = { - val changes = - tools.map { - McpSectionPolicyChange( - it.toolName, - it.providerId, - it.expectedRevocation, - rules[it.toolName], - if (it.toolName in selected) McpPolicyAction.ALLOW else McpPolicyAction.DENY, - ) - } - saving = true - onSaving(true) - scope.launch { - try { - val outcome = onApply(changes) - feedback = outcome.proactivePolicyMessage() ?: "Policies saved." - onDone() - onRefresh() - } finally { - saving = false - onSaving(false) - } - } - }, - ) { Text(if (saving) "Saving…" else confirmLabel, fontSize = 12.sp) } - } - feedback?.let { Text(it, color = colors.textSecondary, fontSize = 12.sp) } -} - internal fun policyToolsHeading( sections: List?, available: List, @@ -292,7 +237,7 @@ internal fun policyToolsDescription(sections: List?): String = if (sections == null) { "Choose Allow or Deny for a tool without a saved rule. Rules follow the tool name." } else { - "Enable a section or choose All, View, Update, or Custom. Changes require confirmation." + "Enable a section or choose All, View, Edit, or Custom. Changes require confirmation." } @Composable diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionConfirmation.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionConfirmation.kt new file mode 100644 index 0000000000..a7cea7a8b9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionConfirmation.kt @@ -0,0 +1,98 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.mcp.McpProactivePolicyOutcome +import ai.rever.boss.mcp.McpSectionPolicyChange +import ai.rever.boss.plugin.ui.BossTheme +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch + +@Composable +internal fun SectionConfirmation( + tools: List, + rules: Map, + selected: Set, + dirty: Boolean, + onApply: suspend (List) -> McpProactivePolicyOutcome, + onRefresh: () -> Unit, + onSaving: (Boolean) -> Unit, + onDone: () -> Unit, + confirmLabel: String = "Confirm section changes", +) { + val colors = BossTheme.colors + val scope = rememberCoroutineScope() + var saving by remember { mutableStateOf(false) } + var feedback by remember(tools, rules, selected) { mutableStateOf(null) } + var riskConfirmed by remember(tools, rules, selected) { mutableStateOf(false) } + val risks = remember(tools, rules, selected) { sensitiveAllows(tools, selected, rules) } + if (dirty) { + PolicyChangeSummary(tools, rules, selected) + if (riskConfirmed) PolicyAllowRisks(risks, rules) + Button( + enabled = !saving, + colors = + ButtonDefaults.buttonColors( + backgroundColor = colors.signal, + contentColor = colors.onSignal, + ), + onClick = { + if (risks.isNotEmpty() && !riskConfirmed) { + riskConfirmed = true + return@Button + } + val changes = + tools.map { + McpSectionPolicyChange( + it.toolName, + it.providerId, + it.expectedRevocation, + rules[it.toolName], + if (it.toolName in selected) McpPolicyAction.ALLOW else McpPolicyAction.DENY, + ) + } + feedback = null + saving = true + onSaving(true) + scope.launch { + try { + val outcome = onApply(changes) + feedback = outcome.proactivePolicyMessage() ?: "Policies saved." + riskConfirmed = false + if (outcome == McpProactivePolicyOutcome.Saved || + outcome == McpProactivePolicyOutcome.Refused + ) { + onDone() + onRefresh() + } + } finally { + saving = false + onSaving(false) + } + } + }, + ) { Text(policyConfirmLabel(saving, risks.isNotEmpty(), riskConfirmed, confirmLabel), fontSize = 12.sp) } + } + feedback?.let { Text(it, color = colors.textSecondary, fontSize = 12.sp) } +} + +private fun policyConfirmLabel( + saving: Boolean, + risky: Boolean, + reviewed: Boolean, + normal: String, +): String = + when { + saving -> "Saving…" + risky && !reviewed -> "Review sensitive allows" + risky -> "Confirm sensitive allows" + else -> normal + } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionMode.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionMode.kt index e7da767899..eb67ab35bb 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionMode.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpSectionMode.kt @@ -2,15 +2,20 @@ package ai.rever.boss.components.dialogs import ai.rever.boss.mcp.McpPolicyAction -internal enum class McpSectionMode { All, View, Update, Custom } +internal enum class McpSectionMode { All, View, Edit, Custom, None } internal fun savedSectionMode( tools: List, rules: Map, ): McpSectionMode { - val explicit = tools.all { rules[it.toolName] in setOf(McpPolicyAction.ALLOW, McpPolicyAction.DENY) } + val explicit = + tools.all { + rules[it.toolName] == McpPolicyAction.ALLOW || rules[it.toolName] == McpPolicyAction.DENY + } val allowed = tools.filter { rules[it.toolName] == McpPolicyAction.ALLOW }.map { it.toolName }.toSet() - if (!explicit || allowed.isEmpty()) return McpSectionMode.Custom - return listOf(McpSectionMode.All, McpSectionMode.View, McpSectionMode.Update) + if (!explicit || allowed.isEmpty()) { + return if (explicit) McpSectionMode.None else McpSectionMode.Custom + } + return listOf(McpSectionMode.All, McpSectionMode.View, McpSectionMode.Edit) .firstOrNull { sectionSelection(tools, it) == allowed } ?: McpSectionMode.Custom } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt index 3175326d33..700528aebd 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt @@ -365,7 +365,7 @@ class McpPolicyEngine( } val outcome = writeConfig( - key = changes.first().providerId, + key = "${changes.size} tools", logKey = "section", updated = _config.value.copy( diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/search/RememberSpotlightFileIndexer.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/search/RememberSpotlightFileIndexer.kt new file mode 100644 index 0000000000..72f60f3726 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/search/RememberSpotlightFileIndexer.kt @@ -0,0 +1,19 @@ +package ai.rever.boss.search + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** Switching projects cancels the old scan only after composition commits. */ +@Composable +internal fun rememberSpotlightFileIndexer( + owner: SpotlightFileIndexOwner, + projectPath: String, +): FileIndexer? { + var indexer by remember(owner, projectPath) { mutableStateOf(null) } + LaunchedEffect(owner, projectPath) { indexer = owner.indexerFor(projectPath) } + return indexer +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridge.kt index 12e6c7d686..76f0c81942 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/RunConfigServiceBridge.kt @@ -38,6 +38,10 @@ import kotlinx.coroutines.flow.flow * refused before [RunExecutionService] ever sees it, and every other field in the request * (command, arguments, working directory, environment) is discarded rather than trusted, so * tampering with them while reusing a real id buys nothing. + * + * This is process authentication, not per-window or per-project authorization: + * authenticated plugins may still choose the scan path and target window, as the + * in-process provider API permits. No caller-supplied command is executed. */ // One method per RPC the generated service base class declares, plus two small private helpers. @Suppress("TooManyFunctions") diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt index e5eec7ff4a..4d2d63c27c 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSwipeNavScript.kt @@ -69,7 +69,11 @@ internal object BrowserSwipeNavScript { canGoForward: Boolean, ): String = "window.$STATE_PROPERTY = { enabled: $enabled, back: $canGoBack, forward: $canGoForward };" - /** Only typed native values cross this boundary; use the same statement in parity tests. */ + /** + * Only typed native values cross this boundary; use the same statement in parity tests. + * JVM Double rendering yields numeric literals or NaN/Infinity, all valid JS expressions; + * no page-supplied text is interpolated here. + */ fun release(end: ScrollGestureEnd): String { val function = "window.$RELEASE_PROPERTY" return "$function && $function('${end.id}', ${end.cancelled}, ${end.accumX}, ${end.rejected});" diff --git a/composeApp/src/desktopMain/resources/browser/swipe-nav.js b/composeApp/src/desktopMain/resources/browser/swipe-nav.js index 2627f2c8d9..8b649f723c 100644 --- a/composeApp/src/desktopMain/resources/browser/swipe-nav.js +++ b/composeApp/src/desktopMain/resources/browser/swipe-nav.js @@ -142,9 +142,13 @@ // Sheets renders cells on a canvas, with a sibling native scrollbar. Read only geometry, // never cells. Its document overscroll policy and cancelled wheels also cover the canvas // at its boundary, so this explicit adapter allows a NEW outward gesture there. + function isSheetsDocument() { + return w.location && w.location.hostname === 'docs.google.com' && + w.location.pathname.indexOf('/spreadsheets/') === 0; + } + function sheetsBoundary(path) { - if (!w.location || w.location.hostname !== 'docs.google.com' || - w.location.pathname.indexOf('/spreadsheets/') !== 0) return null; + if (!isSheetsDocument()) return null; for (var i = 0; i < path.length; i++) { var el = path[i]; if (!el || typeof el.closest !== 'function') continue; @@ -356,6 +360,8 @@ direction = 0; scrollPath = null; lastWheelEvent = null; + capturedWheel = null; + capturedSheetBoundary = null; sheetBoundary = null; sheetEdgeNavigation = false; reachedCommit = false; @@ -424,6 +430,11 @@ } function onWheel(event) { + // Consume the capture snapshot before a new native contact resets old state. + var hasCapture = capturedWheel === event; + var eventBoundary = hasCapture ? capturedSheetBoundary : null; + capturedWheel = null; + capturedSheetBoundary = null; // Cheap filters precede the synchronous renderer-to-host claim. Do not skip vertical // pixel events: their initial scroll chain must retain ownership if the contact curls. if (switchedOff()) { reset(); return; } @@ -465,7 +476,7 @@ if (!scrollPath) { scrollPath = eventPath(event); - sheetBoundary = capturedWheel === event ? capturedSheetBoundary : sheetsBoundary(scrollPath); + sheetBoundary = hasCapture ? eventBoundary : sheetsBoundary(scrollPath); } if (!sheetBoundary && lastWheelEvent && lastWheelEvent.defaultPrevented) abandon(); @@ -568,7 +579,18 @@ // passive keeps this observer off Chromium's scroll-blocking path. w.addEventListener('wheel', function (event) { capturedWheel = event; - capturedSheetBoundary = switchedOff() || event.deltaMode !== 0 ? null : sheetsBoundary(eventPath(event)); + capturedSheetBoundary = switchedOff() || event.deltaMode !== 0 || !isSheetsDocument() + ? null : sheetsBoundary(eventPath(event)); + // A page may stop propagation before our bubble listener. Release that event's target + // after dispatch anyway, rather than retaining a detached subtree until another wheel. + if (typeof w.queueMicrotask === 'function') { + w.queueMicrotask(function () { + if (capturedWheel === event) { + capturedWheel = null; + capturedSheetBoundary = null; + } + }); + } }, { capture: true, passive: true }); w.addEventListener('wheel', onWheel, { capture: false, passive: true }); // pagehide is NOT routed through decide() - the page is already unloading, so navigating diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt index 477f30477a..e187f2af9f 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/dialogs/McpProactivePolicyDialogTest.kt @@ -135,6 +135,58 @@ class McpProactivePolicyDialogTest { rule.runOnIdle { assertTrue(closed) } } + @Test fun `view excludes risk-classified vault tools even when declared read only`() { + val vault = McpToolIdentity("secrets_list", "vault", 0, readOnly = true) + val prefixed = vault.copy(toolName = "mcp__boss__secrets_list") + assertTrue(!vault.isViewTool()) + assertTrue(!prefixed.isViewTool()) + assertEquals(emptySet(), sectionSelection(listOf(vault, prefixed), McpSectionMode.View)) + assertEquals( + McpSectionMode.None, + savedSectionMode(listOf(vault), mapOf(vault.toolName to McpPolicyAction.DENY)), + ) + } + + @Test fun `sensitive grants require review and remain retryable after failure`() { + val tools = listOf(McpToolIdentity("secrets_list", "vault", 0, "List vault entries", readOnly = true)) + var writes = 0 + show(windowSize = IntSize(700, 800)) { + McpPolicyManagerDialog( + emptyMap(), + tools, + { true }, + { _, _ -> McpProactivePolicyOutcome.Saved }, + {}, + {}, + sectionTools = tools, + onApplySection = { + writes++ + if (writes == 1) McpProactivePolicyOutcome.Failed("disk") else McpProactivePolicyOutcome.Saved + }, + ) + } + rule.onNodeWithContentDescription("All for all sections").performScrollTo().performClick() + rule.onNodeWithText("Review sensitive allows").performScrollTo().performClick() + rule.runOnIdle { assertEquals(0, writes) } + rule.onNodeWithText("secrets_list · HIGH", substring = true).assertExists() + captureTheme = "sensitive-dark" + closeIsInsideWindow() + rule.onNodeWithText("Confirm sensitive allows").performScrollTo().performClick() + rule.onNodeWithText("Review sensitive allows").assertExists() + rule.onNodeWithText("Could not save", substring = true).assertExists() + rule.onNodeWithText("Review sensitive allows").performScrollTo().performClick() + rule.onNodeWithText("Confirm sensitive allows").performScrollTo().performClick() + rule.runOnIdle { assertEquals(2, writes) } + } + + @Test fun `saved policy search matches plugin names and sensitive review includes denials`() { + val tool = McpToolIdentity("read", "plugin.id", 0, "Read current state", readOnly = true) + val rules = mapOf("read" to McpPolicyAction.DENY) + assertEquals(rules, filterSavedPolicies(rules, listOf(tool), "Documents", mapOf("plugin.id" to "Documents"))) + assertEquals(listOf(tool), sensitiveAllows(listOf(tool), setOf("read"), rules)) + assertEquals("Saved: Ask before running", savedPolicyLabel(McpPolicyAction.ASK)) + } + @Test fun `global none includes sections hidden by search and waits for confirmation`() { val tools = listOf( @@ -225,7 +277,7 @@ class McpProactivePolicyDialogTest { McpToolIdentity("write", "p", 0, readOnly = false), ) assertEquals(setOf("read", "write"), sectionSelection(tools, McpSectionMode.All)) - assertEquals(setOf("write"), sectionSelection(tools, McpSectionMode.Update)) + assertEquals(setOf("write"), sectionSelection(tools, McpSectionMode.Edit)) assertEquals(McpSectionMode.Custom, savedSectionMode(tools, emptyMap())) assertEquals( McpSectionMode.View, diff --git a/docs/review/pr650-followup.md b/docs/review/pr650-followup.md index 69a9b71b66..d05015e0d7 100644 --- a/docs/review/pr650-followup.md +++ b/docs/review/pr650-followup.md @@ -58,3 +58,20 @@ Still to be performed on the paired host/plugin build; no new hardware validatio - On a Mac that has never granted BOSS Input Monitoring, confirm BOSS appears in System Settings > Privacy & Security > Input Monitoring. The app only preflights access and does not prompt; if absent, use the pane's `+` control and select BOSS.app when that option is available. Grant access, then turn swipe navigation off and on to retry. The reviewer requested this check before merge. Keep that requirement visible rather than treating green automated tests as a substitute. + +## Follow-up review: page ownership and performance + +- Scroll a heavy page rapidly with swipe navigation enabled and watch for jank or stalls. + The native contact claim only reads observer state; it must not call back into renderer/navigation. +- Check an ordinary article containing a chart/canvas or an ARIA grid, as well as a page + with root `overscroll-behavior-x: contain` or `none`. A gesture over an unknown canvas/grid + or an explicitly contained scroll chain intentionally stays with the page. This is a + conservative navigation veto, not proof that every such element can scroll horizontally. +- Google Sheets is the explicit exception: a fresh outward gesture at the horizontal edge + can navigate. `.grid-container` and `.native-scrollbar-x` are private page selectors; if + they change, the detector safely falls back to page ownership. Re-test after Sheets changes. +- Repeated Input Monitoring off/on retry should not double-navigate; obsolete observer + generations are ignored even while the previous native tap is winding down. + +Node is a required test dependency for `SwipeNavParityTest`; a missing interpreter or +source fixture fails rather than silently skipping the JavaScript parity check. diff --git a/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt b/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt index 47d8cf712f..1d3ec8d02f 100644 --- a/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt +++ b/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt @@ -7,6 +7,8 @@ import ai.rever.boss.plugin.api.RunConfigurationData import ai.rever.boss.plugin.api.RunConfigurationDataProvider import ai.rever.boss.plugin.api.RunConfigurationTypeData import io.grpc.ManagedChannel +import io.grpc.Status +import java.util.logging.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -19,6 +21,9 @@ import kotlinx.coroutines.launch /** * IPC proxy implementation of RunConfigurationDataProvider. + * Execution uses the host configuration identified by [RunConfigurationData.id]. + * Command, argument, environment and working-directory edits in the supplied copy + * are ignored by the host; rescan and use a currently detected configuration. */ class RunConfigDataProviderProxy( channel: ManagedChannel, @@ -144,7 +149,9 @@ class RunConfigDataProviderProxy( clearLocalExecutionError() } catch (e: kotlinx.coroutines.CancellationException) { throw e - } catch (_: Exception) { + } catch (error: Exception) { + Logger.getLogger(RunConfigDataProviderProxy::class.java.name) + .warning("Run configuration RPC failed: ${Status.fromThrowable(error).code.name}") // Do not infer a stale id from every RPC failure (transport and authorization // failures can reach this path too), or expose arbitrary remote exception text. synchronized(errorLock) { diff --git a/scripts/test/test-swipe-nav.js b/scripts/test/test-swipe-nav.js index bad4091d82..376ace2882 100644 --- a/scripts/test/test-swipe-nav.js +++ b/scripts/test/test-swipe-nav.js @@ -701,6 +701,15 @@ console.log('\ngestures that must not navigate'); p.settle(); eq('a discrete mouse wheel, however far it travels', p.navigated, []); } +{ + const p = newPage(js); + let pathReads = 0; + for (let i = 0; i < 8; i++) { + p.wheelRaw({ deltaMode: 0, deltaX: -10, deltaY: 0, target: p.body, + composedPath: () => { pathReads++; return [p.body]; } }); + } + eq('ordinary pages build one scroll path per contact, not per capture event', pathReads, 1); +} { const p = newPage(js); p.wheelRaw({ deltaMode: 1, deltaX: -40, deltaY: 0, target: p.body, composedPath: () => [p.body] }); From 2a339a2be4f8fde83cdc686caf1627bf4f694c16 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 21:55:21 -0700 Subject: [PATCH 30/49] fix: restore CI after review follow-up changes --- .../boss/search/GlobalSearchIsolationTest.kt | 3 +- .../RememberSpotlightFileIndexerTest.kt | 68 +++++++++++++++++++ .../plugin/ipc/RunConfigDataProviderProxy.kt | 30 +++++--- 3 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/search/RememberSpotlightFileIndexerTest.kt diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchIsolationTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchIsolationTest.kt index 26f639707f..afdc3abc24 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchIsolationTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/GlobalSearchIsolationTest.kt @@ -75,9 +75,10 @@ class GlobalSearchIsolationTest { assertFalse(dialogSource.contains("fileIndexer.indexProject(")) assertTrue(hostSource.contains("onIndexProject = { state.spotlightFileIndexes.ensureIndexed")) assertFalse(dialogSource.contains("FileIndexer(")) - val ownerLookup = "state.spotlightFileIndexes.indexerFor(selectedProject.path)" + val ownerLookup = "rememberSpotlightFileIndexer(state.spotlightFileIndexes, selectedProject.path)" assertTrue(hostSource.contains("val spotlightFileIndexer = $ownerLookup")) assertTrue(hostSource.contains("fileIndexer = spotlightFileIndexer")) + assertFalse(hostSource.contains("state.spotlightFileIndexes.indexerFor(")) } private fun List.fileResults() = filterIsInstance() diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/RememberSpotlightFileIndexerTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/RememberSpotlightFileIndexerTest.kt new file mode 100644 index 0000000000..4fe966a9f5 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/search/RememberSpotlightFileIndexerTest.kt @@ -0,0 +1,68 @@ +package ai.rever.boss.search + +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.createComposeRule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +class RememberSpotlightFileIndexerTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun `committed project changes replace the index while recomposition and reopen reuse it`() { + val scope = CoroutineScope(Job()) + var created = 0 + val owner = + SpotlightFileIndexOwner(scope) { + created++ + FileIndexer() + } + val path = mutableStateOf("/first") + val visible = mutableStateOf(true) + val revision = mutableStateOf(0) + var renderedRevision = -1 + var current: FileIndexer? = null + try { + compose.setContent { + val counter = revision.value + val indexer = if (visible.value) rememberSpotlightFileIndexer(owner, path.value) else null + SideEffect { + current = indexer + renderedRevision = counter + } + } + compose.waitForIdle() + val first = assertNotNull(current) + compose.runOnIdle { revision.value++ } + compose.runOnIdle { + assertEquals(1, renderedRevision) + assertSame(first, current) + visible.value = false + } + compose.runOnIdle { visible.value = true } + compose.waitForIdle() + compose.runOnIdle { + assertSame(first, current) + assertEquals(1, created) + path.value = "/second" + } + compose.waitForIdle() + compose.runOnIdle { + assertNotNull(current) + assertNotSame(first, current) + assertEquals(2, created) + } + } finally { + scope.cancel() + } + } +} diff --git a/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt b/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt index 1d3ec8d02f..13e7814de5 100644 --- a/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt +++ b/plugin-platform/plugin-api-ipc/src/desktopMain/kotlin/ai/rever/boss/plugin/ipc/RunConfigDataProviderProxy.kt @@ -8,7 +8,8 @@ import ai.rever.boss.plugin.api.RunConfigurationDataProvider import ai.rever.boss.plugin.api.RunConfigurationTypeData import io.grpc.ManagedChannel import io.grpc.Status -import java.util.logging.Logger +import io.grpc.StatusException +import io.grpc.StatusRuntimeException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.util.logging.Logger /** * IPC proxy implementation of RunConfigurationDataProvider. @@ -149,15 +151,23 @@ class RunConfigDataProviderProxy( clearLocalExecutionError() } catch (e: kotlinx.coroutines.CancellationException) { throw e - } catch (error: Exception) { - Logger.getLogger(RunConfigDataProviderProxy::class.java.name) - .warning("Run configuration RPC failed: ${Status.fromThrowable(error).code.name}") - // Do not infer a stale id from every RPC failure (transport and authorization - // failures can reach this path too), or expose arbitrary remote exception text. - synchronized(errorLock) { - localExecutionError = "Run could not be started." - _lastError.value = localExecutionError - } + } catch (error: StatusException) { + reportExecutionFailure(error.status.code) + } catch (error: StatusRuntimeException) { + reportExecutionFailure(error.status.code) + } catch (_: Exception) { + // Preserve the UI boundary for non-RPC failures without exposing exception text. + reportExecutionFailure(Status.Code.UNKNOWN) + } + } + + private fun reportExecutionFailure(code: Status.Code) { + Logger + .getLogger(RunConfigDataProviderProxy::class.java.name) + .warning("Run configuration RPC failed: ${code.name}") + synchronized(errorLock) { + localExecutionError = "Run could not be started." + _lastError.value = localExecutionError } } From 7082e2cbb2bec5a9478b60c78cf259a7387b635d Mon Sep 17 00:00:00 2001 From: Vidhaankhare16 <142911723+Vidhaankhare16@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:52:07 +0530 Subject: [PATCH 31/49] fix(run): make run-configuration disambiguation work on Windows (#653) * fix(run): make run-configuration disambiguation work on Windows makeNamesUnique and makeStoredNamesUnique split RunConfiguration.filePath on a literal "/", but a stored filePath is an OS-native absolute path: DesktopMainFunctionDetector assigns file.absolutePath, which is backslash-separated on Windows. split("/") yields one part there, the parts.size >= 2 guard never passes, and two configurations with the same name in different directories stay byte-identical in the Run dropdown. Split on both separators, and take the project name from extractFileName() instead of substringAfterLast('/'). plugin-path-utils documents itself as the single source of truth for path utilities, and RunConfiguration.toShortNameWithProject already builds the initial label with it; this file was the one place re-deriving those values with a hardcoded separator. The project-name change also closes a latent leak: substringAfterLast('/') returns the whole absolute path on Windows, so the label would have read "main (app/Main.kt [C:\Users\\myproject])" once the split was fixed. Both functions become internal so the tests can drive them directly. * fix(run): keep the project bracket and drop empty path segments Addresses the review on 03ba543b. makeStoredNamesUnique replaced the whole trailing "(...)" group, so a stored name of "main (Main.kt [myproject])" became "main (app/Main.kt)" and lost the project bracket that makeNamesUnique rebuilds. That asymmetry is pre-existing on POSIX, but the branch never executed on Windows before this change, so the fix above is what makes it reachable there. loadSettingsSync assigns the rewritten names to _currentSettings and the next saveSettings persists them, so the loss is not display-only. The bracket is now carried across. Both splits drop empty segments, matching DesktopMainFunctionDetector .detectModuleName. A doubled separator can reach these functions through a hand-edited run-configurations.json, and without the filter takeLast(2) picks up the empty segment and labels it "/Main.kt". The filter subsumes the leading separator trim, which is removed rather than left as dead belt-and-braces. The project name is now read from projectPath.trimEnd('/', '\'), so a project path with a trailing separator still yields a bracket instead of dropping it. Both regexes move to object-level vals: the trailing-group pattern was duplicated across the two functions, and neither needs recompiling per element. Tests go from 10 to 16, adding bracketed stored names on both platforms, a doubled separator in each function, idempotency of the stored rewrite, and a projectPath that is not an exact prefix of filePath. --- .../run/DesktopRunConfigurationManager.kt | 32 +- .../run/RunConfigurationNameSeparatorTest.kt | 290 ++++++++++++++++++ 2 files changed, 313 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/run/RunConfigurationNameSeparatorTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/run/DesktopRunConfigurationManager.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/run/DesktopRunConfigurationManager.kt index 8438041ab3..3055e20280 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/run/DesktopRunConfigurationManager.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/run/DesktopRunConfigurationManager.kt @@ -1,6 +1,7 @@ package ai.rever.boss.run import ai.rever.boss.plugin.pathutils.BossDirectories +import ai.rever.boss.utils.extractFileName import ai.rever.boss.utils.logging.BossLogger import ai.rever.boss.utils.logging.LogCategory import kotlinx.coroutines.Dispatchers @@ -24,6 +25,12 @@ actual object RunConfigurationManager { ignoreUnknownKeys = true } + /** The trailing " (...)" group of a configuration name, which disambiguation rewrites. */ + private val trailingGroupRegex = Regex("\\([^)]+\\)$") + + /** The " [Project]" part inside that group, present when the name came from a project scan. */ + private val trailingProjectRegex = Regex("( \\[[^\\]]*])\\)$") + private val detector = DesktopMainFunctionDetector() private val _currentSettings = MutableStateFlow(RunConfigurationSettings()) @@ -96,7 +103,7 @@ actual object RunConfigurationManager { /** * Make stored configuration names unique using parent directory context. */ - private fun makeStoredNamesUnique(configs: List): List { + internal fun makeStoredNamesUnique(configs: List): List { val nameGroups = configs.groupBy { it.name } return configs.map { config -> @@ -104,12 +111,19 @@ actual object RunConfigurationManager { if (group.size <= 1) { config } else { - // Add parent directory to make unique - val parts = config.filePath.split("/") + // Add parent directory to make unique. A stored filePath is an OS-native + // absolute path (File.absolutePath), so split on both separators. Empty segments + // are dropped the way DesktopMainFunctionDetector.detectModuleName drops them: + // this path is read from run-configurations.json, which is hand-editable, and a + // doubled separator would otherwise put "" into takeLast(2) and label it "/Main.kt". + val parts = config.filePath.split('/', '\\').filter { it.isNotEmpty() } val uniqueName = if (parts.size >= 2) { val parentAndFile = parts.takeLast(2).joinToString("/") - config.name.replace(Regex("\\([^)]+\\)$")) { "($parentAndFile)" } + // Keep any " [Project]" the stored name already carries: the trailing-group + // regex would otherwise consume it, and makeNamesUnique rebuilds it. + val projectSuffix = trailingProjectRegex.find(config.name)?.groupValues?.get(1) ?: "" + config.name.replace(trailingGroupRegex) { "($parentAndFile$projectSuffix)" } } else { config.name } @@ -158,13 +172,13 @@ actual object RunConfigurationManager { * E.g., two "main (Main.kt [Project])" become "main (app/Main.kt [Project])" and "main (lib/Main.kt [Project])" * Preserves the project name in brackets if present. */ - private fun makeNamesUnique( + internal fun makeNamesUnique( configs: List, projectPath: String, ): List { // Group by name to find duplicates val nameGroups = configs.groupBy { it.name } - val projectName = projectPath.substringAfterLast('/').takeIf { it.isNotBlank() } + val projectName = projectPath.trimEnd('/', '\\').extractFileName().takeIf { it.isNotBlank() } return configs.map { config -> val group = nameGroups[config.name] ?: return@map config @@ -172,14 +186,14 @@ actual object RunConfigurationManager { config } else { // Add parent directory to make unique, preserving project name - val relativePath = config.filePath.removePrefix(projectPath).removePrefix("/") - val parts = relativePath.split("/") + val relativePath = config.filePath.removePrefix(projectPath) + val parts = relativePath.split('/', '\\').filter { it.isNotEmpty() } val uniqueName = if (parts.size >= 2) { // Include parent directory: "main (parent/Main.kt [Project])" val parentAndFile = parts.takeLast(2).joinToString("/") val projectSuffix = if (projectName != null) " [$projectName]" else "" - config.name.replace(Regex("\\([^)]+\\)$")) { "($parentAndFile$projectSuffix)" } + config.name.replace(trailingGroupRegex) { "($parentAndFile$projectSuffix)" } } else { config.name } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/run/RunConfigurationNameSeparatorTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/run/RunConfigurationNameSeparatorTest.kt new file mode 100644 index 0000000000..288f437533 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/run/RunConfigurationNameSeparatorTest.kt @@ -0,0 +1,290 @@ +package ai.rever.boss.run + +import ai.rever.boss.plugin.run.Language +import ai.rever.boss.plugin.run.RunConfiguration +import ai.rever.boss.plugin.run.RunConfigurationType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Locks down the separator handling in run-configuration name disambiguation. + * + * A stored [RunConfiguration.filePath] is an OS-native absolute path, because + * DesktopMainFunctionDetector assigns `file.absolutePath`. On Windows that is + * backslash-separated, so neither disambiguation pass may assume a forward slash. + * + * Both path shapes are exercised on every CI leg: these are pure string functions + * with no filesystem access, so the Windows expectations are just as meaningful on + * a Linux runner as on a Windows one. + */ +class RunConfigurationNameSeparatorTest { + private fun config( + name: String, + filePath: String, + ) = RunConfiguration( + id = filePath, + name = name, + type = RunConfigurationType.MAIN_FUNCTION, + filePath = filePath, + lineNumber = 1, + language = Language.KOTLIN, + command = "", + workingDirectory = "", + ) + + @Test + fun `windows paths disambiguate two same-named configurations`() { + val project = """C:\Users\dev\myproject""" + val configs = + listOf( + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals( + 2, + names.toSet().size, + "Disambiguation is the whole purpose of makeNamesUnique, but on Windows paths " + + "both entries stayed identical: $names", + ) + assertTrue(names.any { it.contains("app/Main.kt") }, "Expected the app parent in $names") + assertTrue(names.any { it.contains("lib/Main.kt") }, "Expected the lib parent in $names") + } + + @Test + fun `windows label carries the project name, not the absolute project path`() { + val project = """C:\Users\dev\myproject""" + val configs = + listOf( + config("main (Main.kt)", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt)", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + names.forEach { name -> + assertFalse( + name.contains("""C:\Users\dev"""), + "A run-configuration label must not embed the absolute project path: $name", + ) + } + assertTrue(names.all { it.contains("[myproject]") }, "Expected the project name in $names") + } + + /** + * `removePrefix` is exact, so a projectPath differing from filePath in drive-letter case does + * not match and the relativisation is a no-op. `takeLast(2)` of the absolute path still yields + * a parent/file label, so the fallback cannot leak the full path either. + */ + @Test + fun `a project path that is not an exact prefix still cannot leak the absolute path`() { + val project = """c:\Users\dev\myproject""" + val configs = + listOf( + config("main (Main.kt)", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt)", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + @Test + fun `posix paths keep their existing disambiguation`() { + val project = "/home/dev/myproject" + val configs = + listOf( + config("main (Main.kt [myproject])", "/home/dev/myproject/app/Main.kt"), + config("main (Main.kt [myproject])", "/home/dev/myproject/lib/Main.kt"), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + @Test + fun `a single configuration is never renamed`() { + val project = """C:\Users\dev\myproject""" + val configs = listOf(config("main (Main.kt)", """C:\Users\dev\myproject\app\Main.kt""")) + + assertEquals(configs, RunConfigurationManager.makeNamesUnique(configs, project)) + } + + /** + * A file directly in the project root has no parent segment to disambiguate with, so its name + * must be left alone. The empty-segment filter is what makes that hold: the relative path keeps + * its leading separator, and without the filter it splits to a leading empty segment, clears + * the `size >= 2` guard on it and produces a label of "/Main.kt". + */ + @Test + fun `a windows file at the project root keeps its name`() { + val project = """C:\Users\dev\myproject""" + val configs = + listOf( + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\Main.kt"""), + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\app\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (Main.kt [myproject])", "main (app/Main.kt [myproject])"), names) + } + + @Test + fun `a posix file at the project root keeps its name`() { + val project = "/home/dev/myproject" + val configs = + listOf( + config("main (Main.kt [myproject])", "/home/dev/myproject/Main.kt"), + config("main (Main.kt [myproject])", "/home/dev/myproject/app/Main.kt"), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (Main.kt [myproject])", "main (app/Main.kt [myproject])"), names) + } + + /** extractFileName and the split both accept either separator, so a mixed path works too. */ + @Test + fun `mixed separators are handled`() { + val project = """C:/Users\dev/myproject""" + val configs = + listOf( + config("main (Main.kt)", """C:/Users\dev/myproject\app/Main.kt"""), + config("main (Main.kt)", """C:/Users\dev/myproject/lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + /** A trailing separator on the project path must still yield the project name. */ + @Test + fun `a project path with a trailing separator still yields the project name`() { + val project = """C:\Users\dev\myproject\""" + val configs = + listOf( + config("main (Main.kt)", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt)", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + /** + * A doubled interior separator reaches these functions through a hand-edited + * run-configurations.json. Without the empty-segment filter takeLast(2) picks up the empty + * segment and the label becomes "/Main.kt". + */ + @Test + fun `a doubled separator in a scanned path does not produce an empty segment`() { + val project = """C:\p""" + val configs = + listOf( + config("main (Main.kt)", """C:\p\app\\Main.kt"""), + config("main (Main.kt)", """C:\p\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (app/Main.kt [p])", "main (lib/Main.kt [p])"), names) + } + + @Test + fun `stored windows names disambiguate on reload`() { + val configs = + listOf( + config("main (Main.kt)", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt)", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeStoredNamesUnique(configs).map { it.name } + + assertEquals(listOf("main (app/Main.kt)", "main (lib/Main.kt)"), names) + } + + @Test + fun `stored posix names keep their existing disambiguation`() { + val configs = + listOf( + config("main (Main.kt)", "/home/dev/myproject/app/Main.kt"), + config("main (Main.kt)", "/home/dev/myproject/lib/Main.kt"), + ) + + val names = RunConfigurationManager.makeStoredNamesUnique(configs).map { it.name } + + assertEquals(listOf("main (app/Main.kt)", "main (lib/Main.kt)"), names) + } + + /** + * Stored names are toShortNameWithProject output, so they carry a bracketed project name. The + * trailing-group regex would consume that bracket, so makeStoredNamesUnique puts it back for + * parity with makeNamesUnique. loadSettingsSync assigns these names to _currentSettings and + * the next saveSettings persists them, so dropping it would not be display-only. + */ + @Test + fun `stored windows names keep the bracketed project suffix`() { + val configs = + listOf( + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeStoredNamesUnique(configs).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + @Test + fun `stored posix names keep the bracketed project suffix`() { + val configs = + listOf( + config("main (Main.kt [myproject])", "/home/dev/myproject/app/Main.kt"), + config("main (Main.kt [myproject])", "/home/dev/myproject/lib/Main.kt"), + ) + + val names = RunConfigurationManager.makeStoredNamesUnique(configs).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + @Test + fun `a doubled separator in a stored path does not produce an empty segment`() { + val configs = + listOf( + config("main (Main.kt)", """C:\p\app\\Main.kt"""), + config("main (Main.kt)", """C:\p\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeStoredNamesUnique(configs).map { it.name } + + assertEquals(listOf("main (app/Main.kt)", "main (lib/Main.kt)"), names) + } + + /** + * makeStoredNamesUnique runs on every launch and loadSettingsSync persists its output, so a + * non-idempotent rewrite would make labels drift on each start. + */ + @Test + fun `stored name rewriting is idempotent`() { + val configs = + listOf( + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val once = RunConfigurationManager.makeStoredNamesUnique(configs) + val twice = RunConfigurationManager.makeStoredNamesUnique(once) + + assertEquals(once.map { it.name }, twice.map { it.name }) + } +} From 474f6c9887f0cf3de86a4daed6d2290d75dbbe57 Mon Sep 17 00:00:00 2001 From: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:53:27 +0530 Subject: [PATCH 32/49] Gate JxBrowser auto-release on the compile-classpath modules (#678) * Gate JxBrowser auto-release on the compile-classpath modules The autorelease watcher's artifact-readiness probe only checked the core jxbrowser jar and the platform binaries. But the desktop compile classpath also needs jxbrowser-compose and jxbrowser-swing (plus jxbrowser-kotlin, transitive of compose), each published as its own Maven module whose propagation can lag the binaries. When JxBrowser 9.5.1 landed, the binaries were resolvable but those modules were not, so the gate passed, a build was dispatched, and every branding matrix job failed at desktopCompileClasspath with: Could not find com.teamdev.jxbrowser:jxbrowser-compose:9.5.1 Add the compile-classpath modules to the probe so a version is not dispatched until every artifact the branding build actually resolves is available. Fixes #667 * test: cover missing JxBrowser compile modules in release gate --------- Co-authored-by: Shivang --- .github/scripts/check-jxbrowser-release.sh | 12 ++++++++++++ scripts/test/test-jxbrowser-autorelease.sh | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/scripts/check-jxbrowser-release.sh b/.github/scripts/check-jxbrowser-release.sh index 4af6c04c28..ab7c2be83d 100755 --- a/.github/scripts/check-jxbrowser-release.sh +++ b/.github/scripts/check-jxbrowser-release.sh @@ -59,8 +59,20 @@ probe_required_artifacts() { # Metadata can lead platform artifacts briefly. HEAD is intentional: a range # request could download an entire Chromium JAR if an intermediary ignored it. # Windows ARM64 is optional in the release matrix, so it is not a hard gate. + # + # jxbrowser-compose and jxbrowser-swing are on the desktop compile classpath + # (composeApp/build.gradle.kts), and jxbrowser-kotlin is pulled in transitively + # by jxbrowser-compose. All three publish as their own Maven modules, so their + # propagation can lag the platform binaries. Probing only the binaries let + # JxBrowser 9.5.1 dispatch while those modules were still unresolvable, and the + # branding build failed at `desktopCompileClasspath` with "Could not find + # com.teamdev.jxbrowser:jxbrowser-compose:9.5.1" (BossConsole#667). Gate on the + # compile-classpath modules too, not just the binaries. local artifacts=( jxbrowser + jxbrowser-compose + jxbrowser-swing + jxbrowser-kotlin jxbrowser-linux64 jxbrowser-linux64-arm jxbrowser-mac diff --git a/scripts/test/test-jxbrowser-autorelease.sh b/scripts/test/test-jxbrowser-autorelease.sh index a90d4f1b1a..41f4fadcc8 100755 --- a/scripts/test/test-jxbrowser-autorelease.sh +++ b/scripts/test/test-jxbrowser-autorelease.sh @@ -20,6 +20,8 @@ if [[ "$*" == *"maven-metadata.xml"* ]]; then printf '%s\n' \ "${MOCK_VERSION:-9.4.0}" fi +elif [[ -n "${MOCK_MISSING_ARTIFACT:-}" && "$*" == *"/${MOCK_MISSING_ARTIFACT}/"* ]]; then + exit 22 elif [[ "${MOCK_ARTIFACT_READY:-true}" != "true" ]]; then exit 22 elif [[ "$*" != *"--head"* ]]; then @@ -308,4 +310,18 @@ if verify_chromium_release_assets 'first.zip' 'Test release' > "$test_dir/verify fi echo "✓ missing_release_asset" + +for module in jxbrowser-compose jxbrowser-swing jxbrowser-kotlin; do + export MOCK_MISSING_ARTIFACT="$module" + run_watch_case "missing-$module" false artifact_not_ready true '[[]]' '[]' + assert_output "$test_dir/missing-$module.output" "blocked_artifact=$module" + if PATH="$mock_bin:$PATH" CHECK_MODE=preflight TARGET_VERSION=9.4.0 MOCK_RELEASE_PAGES='[[]]' SUPABASE_SERVICE_ROLE_KEY=test-key REQUIRED_ASSETS_FILE="$assets_file" GITHUB_OUTPUT="$test_dir/preflight-$module.output" bash "$detector" > "$test_dir/preflight-$module.log" 2>&1; then + echo "FAILED: preflight accepted absent $module" >&2 + exit 1 + fi + assert_output "$test_dir/preflight-$module.output" "blocked_artifact=$module" + echo "✓ preflight missing $module rejected" + unset MOCK_MISSING_ARTIFACT +done + echo "All JxBrowser release automation tests passed." From 5f15d98233bdcebbcce3418763a1cb49d7956bb2 Mon Sep 17 00:00:00 2001 From: Vidhaankhare16 <142911723+Vidhaankhare16@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:57:34 +0530 Subject: [PATCH 33/49] feat(cli): report workspace health in boss status and add boss doctor (#579) * feat(cli): report workspace health in boss status and add boss doctor boss status --json gains an additive health object built from state BOSS already keeps: sandbox watchdog disables and plugin health rows from #454, a browser engine that is missing, failed or unresponsive, and MCP kill-switch and policy faults. boss doctor prints the same report, one line per finding with a suggested next step, and exits 2 while anything is reported. Nothing is changed by either command. doctor is registered in createBossCLI, the headless gate in CliBootstrap.isHeadlessCli, OsOpenArguments.CLI_SUBCOMMANDS and the three launchers. OsOpenArgumentsTest now checks CLI_SUBCOMMANDS against createBossCLI().registeredSubcommands(), and CliBootstrapTest pins doctor as a headless command. Refs #418 * fix(cli): keep health inspection read-only and qualify unchecked reports * fix(cli): contain each window's plugin health source and report partial coverage WorkspaceHealthSources.pluginSnapshots() mapped every registered window in one expression, so one source that threw propagated out and the collector's per-area try turned the whole plugins area into unchecked. A watchdog-stopped plugin in window A disappeared whenever window B's source failed, which is exactly the case the feature exists to report. Each window source is now contained on its own. Every snapshot that could be read is kept, failures are counted, and each failure is logged through BossLogger with LogCategory.SYSTEM the way the collector already logs an unreadable area. unchecked still means nothing could be read at all. A new additive partial set says an area was read from several sources and some of them failed, so the findings listed are real but do not cover the area. An area is never in both. boss doctor prints a "Partially checked:" line and stops saying "No problems found."; boss status appends "partially checked: plugins" to its Health line. Today only plugins can be partial, because it is the only area with one source per window. partial is additive on the wire: a reader that does not know the field sees what it saw before, and both CLI commands read an absent partial as empty, so an older CLI against a newer BOSS and the reverse both still parse. WorkspaceHealthSourcesTest now registers a failing source beside one that reports a stopped plugin and asserts through the real collector that the stopped plugin is still reported and the area is declared partial. Refs #418 --------- Co-authored-by: Shivang --- composeApp/detekt-baseline.xml | 2 +- .../rever/boss/app/BossAppStartupEffects.kt | 10 + .../plugin/PluginHealthCenterDialog.kt | 16 +- .../components/plugin/PluginHealthSnapshot.kt | 36 +++ .../ai/rever/boss/health/WorkspaceHealth.kt | 241 +++++++++++++++ .../rever/boss/health/WorkspaceHealthJson.kt | 39 +++ .../boss/health/WorkspaceHealthSources.kt | 91 ++++++ .../kotlin/ai/rever/boss/cli/BossCommand.kt | 2 + .../ai/rever/boss/cli/BossDoctorCommand.kt | 137 +++++++++ .../boss/config/ChromiumAutoDownloader.kt | 28 +- .../boss/health/WorkspaceHealthCollector.kt | 107 +++++++ .../rever/boss/plugin/browser/FluckEngine.kt | 4 + .../ai/rever/boss/startup/CliBootstrap.kt | 4 +- .../ai/rever/boss/utils/OsOpenArguments.kt | 2 +- .../rever/boss/utils/SingleInstanceManager.kt | 13 +- .../ai/rever/boss/cli/BossDoctorCliTest.kt | 252 ++++++++++++++++ .../boss/config/ChromiumAutoDownloaderTest.kt | 27 ++ .../health/WorkspaceHealthCollectorTest.kt | 97 ++++++ .../boss/health/WorkspaceHealthReportTest.kt | 283 ++++++++++++++++++ .../boss/health/WorkspaceHealthSourcesTest.kt | 94 ++++++ .../ai/rever/boss/startup/CliBootstrapTest.kt | 1 + .../rever/boss/utils/OsOpenArgumentsTest.kt | 28 +- .../boss/utils/StatusHealthWiringTest.kt | 41 +++ docs/CLI.md | 43 +++ docs/mcp-agentless-operators.md | 5 + scripts/boss | 3 +- scripts/boss.bat | 2 + scripts/boss.ps1 | 3 +- 28 files changed, 1558 insertions(+), 53 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthSnapshot.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealth.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthJson.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthSources.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/health/WorkspaceHealthCollector.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/BossDoctorCliTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthCollectorTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthReportTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthSourcesTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/StatusHealthWiringTest.kt diff --git a/composeApp/detekt-baseline.xml b/composeApp/detekt-baseline.xml index 419dac4c7d..e539a373d6 100644 --- a/composeApp/detekt-baseline.xml +++ b/composeApp/detekt-baseline.xml @@ -1094,7 +1094,7 @@ ReturnCount:CLISecurityValidator.kt$CLISecurityValidator$fun isValidOpenTargetPath(path: String): Boolean ReturnCount:CLISecurityValidator.kt$CLISecurityValidator$fun isValidPath(path: String): Boolean ReturnCount:CLISecurityValidator.kt$CLISecurityValidator$fun normalizeAndValidateUrl(url: String): String? - ReturnCount:ChromiumAutoDownloader.kt$ChromiumAutoDownloader$fun isChromiumInstalled(): Boolean + ReturnCount:ChromiumAutoDownloader.kt$ChromiumAutoDownloader$internal fun chromiumInstalledAt( dir: Path = getChromiumDir(), requiredVersion: String = effectiveVersion, isMac: Boolean = System.getProperty("os.name").lowercase().contains("mac"), repairAttempted: () -> Boolean = ::repairAlreadyAttempted, recordRepair: () -> Unit, ): Boolean ReturnCount:ChromiumAutoDownloader.kt$ChromiumAutoDownloader$internal fun promotePendingInstall( pending: File, target: File, backup: File, ) ReturnCount:ChromiumReleaseSource.kt$ChromiumReleaseResolver.Companion$private fun comparePreRelease( a: List<String>, b: List<String>, ): Int ReturnCount:CrossDeviceAuthService.kt$CrossDeviceAuthService$suspend fun handleCrossDeviceAuthentication( exception: CrossDeviceAuthenticationRequired, onAuthenticationComplete: suspend (PasskeyAuthenticationResult) -> Result<Unit>, ): Result<Unit> diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppStartupEffects.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppStartupEffects.kt index ef222f3dbe..4cf2c42a54 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppStartupEffects.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppStartupEffects.kt @@ -2,6 +2,7 @@ package ai.rever.boss.app import ai.rever.boss.components.plugin.DefaultPlugin import ai.rever.boss.components.plugin.PluginUpdateRegistry +import ai.rever.boss.components.plugin.currentPluginHealth import ai.rever.boss.components.plugin.tab_types.fluck.FluckTabInfo import ai.rever.boss.components.plugin.tab_types.registerPanelHostTab import ai.rever.boss.components.registery.PanelComponentStoreRegistry @@ -26,6 +27,7 @@ import ai.rever.boss.components.workspaces.tabListChanges import ai.rever.boss.components.workspaces.workspaceManager import ai.rever.boss.consumePendingInitialProject import ai.rever.boss.consumePendingInitialTab +import ai.rever.boss.health.WorkspaceHealthSources import ai.rever.boss.performance.BrowserTabInfo import ai.rever.boss.performance.EditorTabResourceInfo import ai.rever.boss.performance.PerformanceState @@ -445,7 +447,15 @@ internal fun BossAppStartupEffects(state: BossAppState) { ai.rever.boss.services.editor.EditorAPIAccess .initialize(plugin) + // Let `boss status` and `boss doctor` report this window's plugin health. DefaultPlugin's + // init already created the manager, so a health query reads it and creates nothing. + val pluginHealthSource = { currentPluginHealth(plugin.dynamicPluginManager) } + WorkspaceHealthSources.registerPlugins(windowId, pluginHealthSource) + onDispose { + // Unregister first, before the plugin is disposed, so a health query never reads a disposed manager. + WorkspaceHealthSources.unregisterPlugins(windowId, pluginHealthSource) + // NOTE: Browser disposal moved to main.kt onCloseRequest handler // Browsers must be disposed BEFORE Compose disposal begins, not during it // See main.kt onCloseRequest for the disposeAllBrowsersBlocking() call diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthCenterDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthCenterDialog.kt index abdee8ee85..3d09f76c24 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthCenterDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthCenterDialog.kt @@ -284,21 +284,7 @@ private fun kotlinx.coroutines.CoroutineScope.launchHealthAction( private fun currentHealthAction( manager: DynamicPluginManager, pluginId: String, -): PluginHealthAction? { - val states = manager.pluginStates.value - val user = AuthStateManager.currentUser.value - return healthRowsWithSandboxDisables( - pluginHealthRows( - pluginStates = states, - loadGates = PluginLoadGateRegistry.gates.value, - crashedPluginIds = states.keys.filterTo(mutableSetOf()) { PluginCrashRegistry.hasCrashed(it) }, - inaccessiblePluginIds = - healthInaccessiblePluginIds(states, user?.isAdmin == true, user?.permissions?.toSet() ?: emptySet()), - incompatiblePluginIds = PluginCrashRegistry.incompatiblePlugins.value, - ), - states.keys.filterTo(mutableSetOf()) { manager.sandboxManager.isPluginDisabled(it) }, - ).firstOrNull { it.pluginId == pluginId }?.action -} +): PluginHealthAction? = currentPluginHealth(manager).rows.firstOrNull { it.pluginId == pluginId }?.action @Composable private fun PluginHealthRowCard( diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthSnapshot.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthSnapshot.kt new file mode 100644 index 0000000000..4015784978 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthSnapshot.kt @@ -0,0 +1,36 @@ +package ai.rever.boss.components.plugin + +import ai.rever.boss.plugin.sandbox.ui.PluginCrashRegistry +import ai.rever.boss.services.auth.AuthStateManager + +/** + * One window's plugin health, read from current values rather than composition state. + * + * [rows] are exactly what the Plugin Health & Recovery center shows for that window. + */ +internal data class PluginHealthSnapshot( + val rows: List, + /** Plugins this window's sandbox watchdog stopped after repeated failures. */ + val sandboxDisabledPluginIds: Set, +) + +/** + * Snapshot [manager]'s plugin health with one read of each value and no composition, so it is safe + * to call off the UI thread. The Plugin Health & Recovery center uses it to re-check an action + * before running it; the headless workspace health report uses it for every open window. + */ +internal fun currentPluginHealth(manager: DynamicPluginManager): PluginHealthSnapshot { + val states = manager.pluginStates.value + val user = AuthStateManager.currentUser.value + val rows = + pluginHealthRows( + pluginStates = states, + loadGates = PluginLoadGateRegistry.gates.value, + crashedPluginIds = states.keys.filterTo(mutableSetOf()) { PluginCrashRegistry.hasCrashed(it) }, + inaccessiblePluginIds = + healthInaccessiblePluginIds(states, user?.isAdmin == true, user?.permissions?.toSet() ?: emptySet()), + incompatiblePluginIds = PluginCrashRegistry.incompatiblePlugins.value, + ) + val sandboxDisabled = states.keys.filterTo(mutableSetOf()) { manager.sandboxManager.isPluginDisabled(it) } + return PluginHealthSnapshot(healthRowsWithSandboxDisables(rows, sandboxDisabled), sandboxDisabled) +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealth.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealth.kt new file mode 100644 index 0000000000..3796010d6b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealth.kt @@ -0,0 +1,241 @@ +package ai.rever.boss.health + +import ai.rever.boss.components.plugin.PluginHealthSnapshot +import ai.rever.boss.components.plugin.PluginHealthStatus +import ai.rever.boss.mcp.McpKillSwitchFault +import ai.rever.boss.mcp.McpPolicyFault + +/** Where a health finding comes from. [wireName] is the value `boss status --json` sends. */ +internal enum class HealthArea( + val wireName: String, +) { + PLUGINS("plugins"), + BROWSER("browser"), + MCP("mcp"), +} + +/** How much of the workspace a finding takes away. Declared most severe first. */ +internal enum class HealthSeverity( + val wireName: String, +) { + /** + * Something that should work is broken and a whole capability is gone: a browser engine that + * failed, or every MCP tool. + */ + CRITICAL("critical"), + + /** Something narrower: one plugin, one setting that was not saved, or an engine not downloaded yet. */ + WARNING("warning"), +} + +/** Stable identifiers scripts can match on. Summaries are for people and may be reworded. */ +internal object HealthCodes { + const val PLUGIN_STOPPED = "plugin_stopped_after_failures" + const val PLUGIN_NEEDS_ATTENTION = "plugin_needs_attention" + const val BROWSER_ENGINE_NOT_INSTALLED = "browser_engine_not_installed" + const val BROWSER_ENGINE_UNAVAILABLE = "browser_engine_unavailable" + const val BROWSER_ENGINE_UNRESPONSIVE = "browser_engine_unresponsive" + const val MCP_TOOLS_WITHHELD = "mcp_tools_withheld" + const val MCP_TOOL_SETTING_NOT_SAVED = "mcp_tool_setting_not_saved" + const val MCP_POLICY_UNREADABLE = "mcp_policy_unreadable" + const val MCP_POLICY_NOT_SAVED = "mcp_policy_not_saved" +} + +/** One problem the workspace is known to have, with a suggested next step when there is one. */ +internal data class HealthFinding( + val area: HealthArea, + val severity: HealthSeverity, + val code: String, + val summary: String, + val subject: String? = null, + val remedy: String? = null, +) + +/** + * The workspace health report. + * + * Only [findings] make the workspace degraded. [unchecked] areas could not be read at all, so they + * are reported as neither healthy nor degraded. [partial] areas are read from more than one source + * and at least one of those sources failed: the findings listed for them are real, but they do not + * cover the whole area, so an empty result there is not a clean bill of health. + */ +internal data class WorkspaceHealthReport( + val findings: List, + val unchecked: Set = emptySet(), + val partial: Set = emptySet(), +) { + val degraded: Boolean get() = findings.isNotEmpty() +} + +internal sealed interface BrowserEngineHealth { + data object Healthy : BrowserEngineHealth + + /** No usable engine is installed yet. [reason] is the engine's own advice on what to do. */ + data class NotInstalled( + val reason: String, + ) : BrowserEngineHealth + + /** The engine could not start. [reason] is the engine's own operator-facing explanation. */ + data class Unavailable( + val reason: String, + ) : BrowserEngineHealth + + /** The engine stopped creating browsers and the wedge detector has spent its recycle budget. */ + data object Unresponsive : BrowserEngineHealth +} + +/** The MCP fault states the bottom status bar shows. */ +internal data class McpFaults( + val killSwitch: McpKillSwitchFault?, + val policy: McpPolicyFault?, +) + +/** + * What a report is built from. A null source could not be read and is reported as unchecked. + * + * [pluginSourceFailures] is how many window sources threw while [plugins] was being read. It is + * only meaningful beside a non-null [plugins]: when no window answered at all the area is unchecked, + * which already says nothing was read. + */ +internal data class WorkspaceHealthInputs( + val plugins: List?, + val browser: BrowserEngineHealth?, + val mcp: McpFaults?, + val pluginSourceFailures: Int = 0, +) + +private val findingOrder: Comparator = + compareBy({ it.severity.ordinal }, { it.area.ordinal }, { it.subject.orEmpty() }, { it.code }) + +/** + * Turn state BOSS already tracks into findings. Pure: no clock, no I/O, no side effects. + * + * Only problems count. A plugin the user disabled, or one the signed-in user may not access, is + * listed as unavailable by the Plugin Health & Recovery center, but it is not a degradation here. + */ +internal fun workspaceHealthReport(inputs: WorkspaceHealthInputs): WorkspaceHealthReport { + val findings = + buildList { + inputs.plugins?.let { addAll(pluginFindings(it)) } + inputs.browser?.let { health -> browserFinding(health)?.let { add(it) } } + inputs.mcp?.killSwitch?.let { add(killSwitchFinding(it)) } + inputs.mcp?.policy?.let { add(policyFinding(it)) } + } + val unchecked = + buildSet { + if (inputs.plugins == null) add(HealthArea.PLUGINS) + if (inputs.browser == null) add(HealthArea.BROWSER) + if (inputs.mcp == null) add(HealthArea.MCP) + } + val partial = + buildSet { + if (inputs.plugins != null && inputs.pluginSourceFailures > 0) add(HealthArea.PLUGINS) + } + return WorkspaceHealthReport(findings.sortedWith(findingOrder), unchecked, partial) +} + +/** + * A plugin the sandbox watchdog stopped is reported as that, once, however many windows stopped + * it. Other plugins needing attention are reported with the health center's own detail. + */ +private fun pluginFindings(snapshots: List): List { + val rows = snapshots.flatMap { it.rows } + val names = rows.associate { it.pluginId to it.displayName } + val stopped = snapshots.flatMapTo(sortedSetOf()) { it.sandboxDisabledPluginIds } + val stoppedFindings = + stopped.map { pluginId -> + HealthFinding( + area = HealthArea.PLUGINS, + severity = HealthSeverity.WARNING, + code = HealthCodes.PLUGIN_STOPPED, + summary = "Plugin '${names[pluginId] ?: pluginId}' was stopped after repeated failures.", + subject = pluginId, + remedy = "Reload it from Help > Plugin Health & Recovery in the affected window, or restart BOSS.", + ) + } + val attentionFindings = + rows + .filter { it.status == PluginHealthStatus.NEEDS_ATTENTION && it.pluginId !in stopped } + .distinctBy { it.pluginId to it.detail } + .map { row -> + HealthFinding( + area = HealthArea.PLUGINS, + severity = HealthSeverity.WARNING, + code = HealthCodes.PLUGIN_NEEDS_ATTENTION, + summary = "Plugin '${row.displayName}': ${row.detail}", + subject = row.pluginId, + remedy = "Open Help > Plugin Health & Recovery for this plugin's recovery options.", + ) + } + return stoppedFindings + attentionFindings +} + +private fun browserFinding(health: BrowserEngineHealth): HealthFinding? = + when (health) { + BrowserEngineHealth.Healthy -> { + null + } + + is BrowserEngineHealth.NotInstalled -> { + HealthFinding( + area = HealthArea.BROWSER, + severity = HealthSeverity.WARNING, + code = HealthCodes.BROWSER_ENGINE_NOT_INSTALLED, + summary = "No usable browser engine is installed, so browser tabs and browser tools cannot run.", + remedy = health.reason, + ) + } + + is BrowserEngineHealth.Unavailable -> { + HealthFinding( + area = HealthArea.BROWSER, + severity = HealthSeverity.CRITICAL, + code = HealthCodes.BROWSER_ENGINE_UNAVAILABLE, + summary = "The browser engine could not start: ${health.reason}", + remedy = "Resolve the cause above, then use Retry in a browser tab or restart BOSS.", + ) + } + + BrowserEngineHealth.Unresponsive -> { + HealthFinding( + area = HealthArea.BROWSER, + severity = HealthSeverity.CRITICAL, + code = HealthCodes.BROWSER_ENGINE_UNRESPONSIVE, + summary = "The browser engine stopped creating browsers and automatic recovery has stopped retrying.", + remedy = "Restart BOSS.", + ) + } + } + +/** + * The fault's own message already names the file and the recovery, so it is the summary. Only a + * disabled-tools file that cannot be read withholds every tool. Any other kill-switch fault is + * reported as a setting that could not be saved, deliberately without listing the fault types, so a + * type added later is still reported rather than breaking this mapping. + */ +private fun killSwitchFinding(fault: McpKillSwitchFault): HealthFinding = + if (fault is McpKillSwitchFault.PersistedSetUnreadable) { + HealthFinding(HealthArea.MCP, HealthSeverity.CRITICAL, HealthCodes.MCP_TOOLS_WITHHELD, fault.message) + } else { + HealthFinding( + area = HealthArea.MCP, + severity = HealthSeverity.WARNING, + code = HealthCodes.MCP_TOOL_SETTING_NOT_SAVED, + summary = fault.message, + subject = (fault as? McpKillSwitchFault.TogglePersistFailed)?.toolName, + ) + } + +/** As [killSwitchFinding]: only a policy file that cannot be read withholds every tool. */ +private fun policyFinding(fault: McpPolicyFault): HealthFinding = + if (fault is McpPolicyFault.PersistedPolicyUnreadable) { + HealthFinding(HealthArea.MCP, HealthSeverity.CRITICAL, HealthCodes.MCP_POLICY_UNREADABLE, fault.message) + } else { + HealthFinding( + area = HealthArea.MCP, + severity = HealthSeverity.WARNING, + code = HealthCodes.MCP_POLICY_NOT_SAVED, + summary = fault.message, + subject = (fault as? McpPolicyFault.PolicyPersistFailed)?.toolName, + ) + } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthJson.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthJson.kt new file mode 100644 index 0000000000..556785bf82 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthJson.kt @@ -0,0 +1,39 @@ +package ai.rever.boss.health + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray + +/** + * The `health` object carried by `boss status --json` and printed by `boss doctor --json`. + * + * Field names and the lowercase [HealthArea.wireName] and [HealthSeverity.wireName] values are a + * contract with scripts and agents: add fields, never rename them. An absent `subject` or `remedy` + * is omitted rather than sent as null. `partial` is additive: a reader that does not know it sees + * exactly what it saw before. + */ +internal fun WorkspaceHealthReport.toJson(): JsonObject = + buildJsonObject { + put("degraded", degraded) + putJsonArray("findings") { + for (finding in findings) { + addJsonObject { + put("area", finding.area.wireName) + put("severity", finding.severity.wireName) + put("code", finding.code) + put("summary", finding.summary) + finding.subject?.let { put("subject", it) } + finding.remedy?.let { put("remedy", it) } + } + } + } + putJsonArray("unchecked") { + for (area in unchecked.sortedBy { it.ordinal }) add(area.wireName) + } + putJsonArray("partial") { + for (area in partial.sortedBy { it.ordinal }) add(area.wireName) + } + } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthSources.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthSources.kt new file mode 100644 index 0000000000..b105a3c62c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthSources.kt @@ -0,0 +1,91 @@ +package ai.rever.boss.health + +import ai.rever.boss.components.plugin.PluginHealthSnapshot +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory +import java.util.concurrent.ConcurrentHashMap + +/** + * What [WorkspaceHealthSources.pluginSnapshots] could read. + * + * [snapshots] holds one entry per window that answered, and [failedSources] counts the windows whose + * source threw. The two together are what lets the report say "these findings are real, but they do + * not cover every window" instead of dropping either half. + */ +internal data class PluginSnapshotRead( + val snapshots: List, + val failedSources: Int = 0, +) + +/** + * Where the app-wide health report finds each window's plugin health. + * + * Every window owns its own `DynamicPluginManager`, created with that window's `DefaultPlugin`, + * and nothing else enumerates them. A window registers a source when its `DefaultPlugin` is + * created and unregisters it before that plugin is disposed. + */ +internal object WorkspaceHealthSources { + private val pluginSources = ConcurrentHashMap PluginHealthSnapshot>() + private val logger = BossLogger.forComponent("WorkspaceHealth") + + fun registerPlugins( + windowId: String, + source: () -> PluginHealthSnapshot, + ) { + pluginSources[windowId] = source + } + + /** + * Remove [source] only while it is still the one registered for [windowId], so an effect that + * re-runs for the same window cannot remove the registration that replaced it. + */ + fun unregisterPlugins( + windowId: String, + source: () -> PluginHealthSnapshot, + ) { + pluginSources.remove(windowId, source) + } + + /** + * One snapshot per registered window that could be read. + * + * Each window source is contained on its own, because a window whose manager is mid-disposal + * must not hide a watchdog-stopped plugin in another window. A source that throws is logged and + * counted in [PluginSnapshotRead.failedSources]; every other window's snapshot is kept. + * LinkageError is caught alongside Exception for the same reason the collector catches it: a + * class missing from one window's plugin path must not take the whole query down. + */ + @Suppress("TooGenericExceptionCaught") + fun pluginSnapshots(): PluginSnapshotRead { + val snapshots = mutableListOf() + var failedSources = 0 + for ((windowId, source) in pluginSources) { + try { + snapshots += source() + } catch (e: Exception) { + failedSources++ + logUnreadable(windowId, e) + } catch (e: LinkageError) { + failedSources++ + logUnreadable(windowId, e) + } + } + return PluginSnapshotRead(snapshots, failedSources) + } + + /** For tests. */ + fun clear() { + pluginSources.clear() + } + + private fun logUnreadable( + windowId: String, + error: Throwable, + ) { + logger.warn( + LogCategory.SYSTEM, + "Plugin health source could not be read for one window", + mapOf("windowId" to windowId, "error" to (error.message ?: error::class.simpleName)), + ) + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt index 12d03f7566..af4a119781 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt @@ -191,6 +191,7 @@ class BossStatusCommand : CliktCommand(name = "status") { val heapPct = mem["heapPercent"]?.jsonPrimitive?.contentOrNull ?: "?" appendLine(" JVM Memory: ${used}MB / ${max}MB ($heapPct%)") } + healthSummaryOf(element)?.let { appendLine(" Health: $it") } } catch (_: Exception) { appendLine(rawJson) } @@ -565,6 +566,7 @@ fun createBossCLI(): BossCommand = BossFolderCommand(), BossTerminalCommand(), BossStatusCommand(), + BossDoctorCommand(), BossMcpCommand(), BossCompletionCommand(), ) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt new file mode 100644 index 0000000000..4e1288e01f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt @@ -0,0 +1,137 @@ +package ai.rever.boss.cli + +import ai.rever.boss.utils.SingleInstanceManager +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull + +/** `boss doctor` exit code: BOSS is running and reported at least one health finding. */ +internal const val EXIT_DEGRADED = 2 + +/** + * Reports workspace health from the running BOSS instance, for agents and scripts that cannot see + * its windows. Read-only: it only reads the `health` object that `boss status --json` carries. + * + * Exit codes: `0` no findings, `2` running but degraded, `1` not running, the query failed, or the + * running BOSS did not return a health report. + */ +class BossDoctorCommand : CliktCommand(name = "doctor") { + override fun help(context: Context) = "Reports problems in the running BOSS instance, with suggested next steps" + + val json by option("--json", help = "Output the health report as JSON").flag(default = false) + + override fun run() { + val rawStatus = SingleInstanceManager.queryStatus().getOrElse { fail("Error: ${it.message}") } + val health = + healthObjectOf(rawStatus) + ?: fail("Error: the running BOSS did not return a health report. Update BOSS to use boss doctor.") + echo(if (json) health.toString() else formatDoctorReport(health)) + if (health.isDegraded()) throw ProgramResult(EXIT_DEGRADED) + } + + private fun fail(message: String): Nothing { + echo(message, err = true) + throw ProgramResult(1) + } +} + +/** The `health` object of a `boss status --json` response, or null when there is none. */ +internal fun healthObjectOf(rawStatus: String): JsonObject? { + val status = + try { + Json.parseToJsonElement(rawStatus) as? JsonObject + } catch (_: SerializationException) { + null + } + return status?.get("health") as? JsonObject +} + +/** The human `boss doctor` report. Fields are read leniently so a newer BOSS can add to the object. */ +internal fun formatDoctorReport(health: JsonObject): String = + buildString { + appendLine("BOSS Doctor") + appendLine("-----------") + val findings = health.findings() + val unchecked = health.uncheckedAreas() + val partial = health.partialAreas() + if (findings.isEmpty()) { + appendLine( + when { + health.isDegraded() -> "BOSS reports degraded health without finding details." + unchecked.isNotEmpty() || partial.isNotEmpty() -> "No problems found in checked areas." + else -> "No problems found." + }, + ) + } else { + for (finding in findings) { + appendLine("[${finding.text("severity") ?: "problem"}] ${finding.text("summary").orEmpty()}") + finding.text("remedy")?.let { appendLine(" Suggested: $it") } + } + appendLine("${problemCount(findings.size)} found.") + } + if (unchecked.isNotEmpty()) appendLine("Not checked: ${unchecked.joinToString(", ")}") + if (partial.isNotEmpty()) { + appendLine( + "Partially checked: ${partial.joinToString(", ")}. Some sources could not be read, " + + "so problems there may be missing.", + ) + } + }.trimEnd() + +/** + * The value of the `Health:` line in `boss status`, or null for a BOSS that does not report health. + * + * An area that was only partly read qualifies the line the same way an unchecked one does, so a + * report that covers some windows and not others never reads as a clean bill of health. + */ +internal fun healthSummaryOf(status: JsonObject): String? { + val health = status["health"] as? JsonObject ?: return null + val count = health.findings().size + val unchecked = health.uncheckedAreas() + val partial = health.partialAreas() + val qualifiers = + buildList { + if (unchecked.isNotEmpty()) add("not checked: ${unchecked.joinToString(", ")}") + if (partial.isNotEmpty()) add("partially checked: ${partial.joinToString(", ")}") + } + val summary = + when { + count > 0 -> "${problemCount(count)} (run 'boss doctor')" + qualifiers.isEmpty() -> "OK" + else -> "No problems found in checked areas" + } + return if (qualifiers.isEmpty()) summary else "$summary; ${qualifiers.joinToString("; ")}" +} + +/** Whether this health object reports a degraded workspace. */ +internal fun JsonObject.isDegraded() = (get("degraded") as? JsonPrimitive)?.booleanOrNull ?: findings().isNotEmpty() + +private fun JsonObject.findings() = (get("findings") as? JsonArray).orEmpty().filterIsInstance() + +private fun JsonObject.uncheckedAreas(): List = areaNames("unchecked") + +/** Areas the running BOSS could read only in part. Absent on a BOSS that predates the field. */ +private fun JsonObject.partialAreas(): List = areaNames("partial") + +private fun JsonObject.areaNames(key: String): List = + (get(key) as? JsonArray).orEmpty().mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + +/** A field on one line. Fault messages can contain line breaks, and the report prints one line per item. */ +private fun JsonObject.text(key: String): String? = + (get(key) as? JsonPrimitive) + ?.contentOrNull + ?.lines() + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?.joinToString(" ") + +private fun problemCount(count: Int): String = if (count == 1) "1 problem" else "$count problems" diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/ChromiumAutoDownloader.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/ChromiumAutoDownloader.kt index 58040d4e40..8b19d64050 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/ChromiumAutoDownloader.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/ChromiumAutoDownloader.kt @@ -184,8 +184,18 @@ object ChromiumAutoDownloader { * Check if Chromium is already installed, valid, and matches the effective * engine version (Settings pin, else the bundled JxBrowser version). */ - fun isChromiumInstalled(): Boolean { - val dir = getChromiumDir() + fun isChromiumInstalled(): Boolean = chromiumInstalledAt(recordRepair = ::recordRepairAttempt) + + /** Check the cache without consuming the startup repair attempt. Safe for status queries. */ + internal fun isChromiumInstalledReadOnly(): Boolean = chromiumInstalledAt(recordRepair = {}) + + internal fun chromiumInstalledAt( + dir: Path = getChromiumDir(), + requiredVersion: String = effectiveVersion, + isMac: Boolean = System.getProperty("os.name").lowercase().contains("mac"), + repairAttempted: () -> Boolean = ::repairAlreadyAttempted, + recordRepair: () -> Unit, + ): Boolean { if (!dir.toFile().exists()) return false // Check executable.name exists (required by JxBrowser) @@ -200,13 +210,13 @@ object ChromiumAutoDownloader { } val installedVersion = versionFile.readText().trim() - if (installedVersion != effectiveVersion) { + if (installedVersion != requiredVersion) { logger.info( LogCategory.BROWSER, "Chromium version mismatch", mapOf( "installed" to installedVersion, - "required" to effectiveVersion, + "required" to requiredVersion, ), ) return false @@ -214,7 +224,7 @@ object ChromiumAutoDownloader { // On macOS, verify the executable has proper permissions // This catches cached Chromium from older versions that didn't set execute bit correctly - if (System.getProperty("os.name").lowercase().contains("mac")) { + if (isMac) { val executableName = executableNameFile.readText().trim() // executable.name holds the bundle name without its suffix (the branding // workflow writes `basename "$APP_BUNDLE" .app`), so the directory on @@ -233,18 +243,18 @@ object ChromiumAutoDownloader { // directory on EVERY launch and re-fetch ~160 MB forever, silently. // The marker lives outside the engine directory because a // re-download replaces that whole directory. - if (repairAlreadyAttempted()) { + if (repairAttempted()) { logger.warn( LogCategory.BROWSER, "Chromium still registers itself as a browser after a re-download; keeping it", - mapOf("version" to effectiveVersion), + mapOf("version" to requiredVersion), ) } else { - recordRepairAttempt() + recordRepair() logger.info( LogCategory.BROWSER, "Cached Chromium still registers itself as a browser, will re-download", - mapOf("version" to effectiveVersion), + mapOf("version" to requiredVersion), ) return false } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/health/WorkspaceHealthCollector.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/health/WorkspaceHealthCollector.kt new file mode 100644 index 0000000000..d3aa634bde --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/health/WorkspaceHealthCollector.kt @@ -0,0 +1,107 @@ +package ai.rever.boss.health + +import ai.rever.boss.config.ChromiumAutoDownloader +import ai.rever.boss.mcp.McpToolRegistryImpl +import ai.rever.boss.plugin.browser.EngineInitError +import ai.rever.boss.plugin.browser.FluckEngine +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory + +/** + * Reads every health source for `boss status` and `boss doctor`. + * + * Read-only by construction: each source is a value another part of BOSS already maintains. The + * sources are parameters so tests can replace them without starting a browser engine. + */ +internal class WorkspaceHealthCollector( + private val pluginSnapshots: () -> PluginSnapshotRead = WorkspaceHealthSources::pluginSnapshots, + private val browserHealth: () -> BrowserEngineHealth = ::currentBrowserEngineHealth, + private val mcpFaults: () -> McpFaults = ::currentMcpFaults, +) { + /** + * Never throws. A source that cannot be read is reported as unchecked, never as healthy. With no + * window open there is no plugin manager to read, so plugins are unchecked as well. + * + * Plugin health has one source per window and [WorkspaceHealthSources.pluginSnapshots] contains + * each of them separately, so plugins go unchecked only when no window answered. When some + * windows answered and others threw, their findings are kept and the area is reported partial + * rather than complete. + */ + fun collect(): WorkspaceHealthReport { + val plugins = read(HealthArea.PLUGINS, pluginSnapshots) + return workspaceHealthReport( + WorkspaceHealthInputs( + plugins = plugins?.snapshots?.takeIf { it.isNotEmpty() }, + browser = read(HealthArea.BROWSER, browserHealth), + mcp = read(HealthArea.MCP, mcpFaults), + pluginSourceFailures = plugins?.failedSources ?: 0, + ), + ) + } + + // A status query must still answer when one health source is broken, so each source is + // contained here. LinkageError is caught alongside Exception because a class missing from + // this path must not take `boss status` down with it; VirtualMachineError is left alone. + @Suppress("TooGenericExceptionCaught") + private fun read( + area: HealthArea, + source: () -> T, + ): T? = + try { + source() + } catch (e: Exception) { + unreadable(area, e) + } catch (e: LinkageError) { + unreadable(area, e) + } + + private fun unreadable( + area: HealthArea, + error: Throwable, + ): Nothing? { + logger.warn( + LogCategory.SYSTEM, + "Workspace health source could not be read", + mapOf("area" to area.wireName, "error" to (error.message ?: error::class.simpleName)), + ) + return null + } + + private companion object { + val logger = BossLogger.forComponent("WorkspaceHealth") + } +} + +/** + * The browser engine as the health report describes it. + * + * Whether an engine is installed is asked first, with the same check start-up uses before it offers + * the download, because a start-up error recorded without an engine only restates that. This is + * deliberately not `FluckEngine.isEngineHealthy()`, which is also false for an engine that is merely + * closed and will be recreated on next use. + */ +internal fun currentBrowserEngineHealth(): BrowserEngineHealth { + if (FluckEngine.resolveEngineDir(cacheHealthy = ChromiumAutoDownloader.isChromiumInstalledReadOnly()) == null) { + return BrowserEngineHealth.NotInstalled(FluckEngine.noUsableEngineReason()) + } + val initError = FluckEngine.initError + return when { + initError != null -> BrowserEngineHealth.Unavailable(initError.reason) + FluckEngine.isWedgeUnrecoverable -> BrowserEngineHealth.Unresponsive + else -> BrowserEngineHealth.Healthy + } +} + +internal fun currentMcpFaults(): McpFaults = + McpFaults( + killSwitch = McpToolRegistryImpl.killSwitchFault.value, + policy = McpToolRegistryImpl.policyFault.value, + ) + +private val EngineInitError.reason: String + get() = + when (this) { + is EngineInitError.LicenseValidation -> message + is EngineInitError.NetworkError -> message + is EngineInitError.Other -> message + } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt index 6bb82f1a5a..f52bfd88a2 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt @@ -608,6 +608,10 @@ object FluckEngine { wedgeUnrecoverable = unrecoverable } + /** Whether the wedge detector has spent its recycle budget; see [reportWedgeUnrecoverable]. */ + internal val isWedgeUnrecoverable: Boolean + get() = wedgeUnrecoverable + val engine: Engine get() = synchronized(engineLock) { diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt index 00a0cd7383..8099b4e2bf 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt @@ -40,7 +40,7 @@ object CliBootstrap { */ fun isHeadlessCli(args: Array): Boolean { val firstNonFlag = args.firstOrNull { !it.startsWith("-") }?.lowercase() - return firstNonFlag in setOf("status", "mcp", "completion") || + return firstNonFlag in setOf("status", "doctor", "mcp", "completion") || (args.isNotEmpty() && args.all { it in setOf("-h", "--help") }) } @@ -59,7 +59,7 @@ object CliBootstrap { CliDispatchResult.Exit(RisaLlmTokenCommand.execute()) } - // Headless CLI commands (status, mcp, completion, --help) target the running + // Headless CLI commands (status, doctor, mcp, completion, --help) target the running // instance or generate output headlessly. isHeadlessCli(args) -> { configureHeadlessLogging() diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt index aebe732718..e983cc75ea 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt @@ -37,7 +37,7 @@ internal object OsOpenArguments { * double-open, which is easy to miss and hard to attribute. */ internal val CLI_SUBCOMMANDS = - setOf("url", "workspace", "file", "folder", "terminal", "status", "mcp", "completion") + setOf("url", "workspace", "file", "folder", "terminal", "status", "doctor", "mcp", "completion") /** What a path on disk is, for deciding which deep link to build. */ internal enum class OpenTargetKind { FILE, DIRECTORY, ABSENT } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt index 0546ae27e3..02d83d7d5a 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt @@ -2,6 +2,8 @@ package ai.rever.boss.utils +import ai.rever.boss.health.WorkspaceHealthCollector +import ai.rever.boss.health.toJson import ai.rever.boss.plugin.api.McpToolResult import ai.rever.boss.plugin.pathutils.BossDirectories import ai.rever.boss.utils.logging.BossLogger @@ -696,8 +698,15 @@ private fun buildLlmTokenResponse(providerOverride: (() -> Result)?): St ) } +/** + * The STATUS response. [healthJson] is a parameter so a test can check that the real response carries + * the health report without starting a browser engine or reading the MCP files. + */ @Suppress("TooGenericExceptionCaught") -private fun buildStatusResponse(statusProviderOverride: (() -> String)?): String { +internal fun buildStatusResponse( + statusProviderOverride: (() -> String)?, + healthJson: () -> JsonObject = { WorkspaceHealthCollector().collect().toJson() }, +): String { val rawJson = if (statusProviderOverride != null) { statusProviderOverride.invoke() @@ -732,6 +741,8 @@ private fun buildStatusResponse(statusProviderOverride: (() -> String)?): String put("heapPercent", heapPercent) }, ) + // Never throws: a health source that cannot be read is reported as unchecked. + put("health", healthJson()) }.toString() } catch (e: Exception) { return RESPONSE_ERROR_PREFIX + (e.message ?: "Failed to query status") diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/BossDoctorCliTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/BossDoctorCliTest.kt new file mode 100644 index 0000000000..9cedeb3d8a --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/BossDoctorCliTest.kt @@ -0,0 +1,252 @@ +package ai.rever.boss.cli + +import ai.rever.boss.utils.SingleInstanceManager +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.core.parse +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BossDoctorCliTest { + private val runtimeDir = Files.createTempDirectory("boss-doctor-cli-test") + private val originalOut = System.out + private val originalErr = System.err + private val out = ByteArrayOutputStream() + private val err = ByteArrayOutputStream() + + @BeforeTest + fun setUp() { + SingleInstanceManager.runtimeDirOverride = runtimeDir.toFile() + SingleInstanceManager.statusProviderOverride = null + System.setOut(PrintStream(out)) + System.setErr(PrintStream(err)) + } + + @AfterTest + fun tearDown() { + System.setOut(originalOut) + System.setErr(originalErr) + SingleInstanceManager.statusProviderOverride = null + SingleInstanceManager.release() + SingleInstanceManager.runtimeDirOverride = null + runtimeDir.toFile().deleteRecursively() + } + + @Test + fun `doctor exits 0 and says so when no problems are reported`() { + serve(HEALTHY) + + assertEquals(0, exitOf("doctor")) + assertTrue(out.toString().contains("No problems found.")) + } + + @Test + fun `doctor exits 2 and lists each finding with its suggestion when degraded`() { + serve(DEGRADED) + + assertEquals(EXIT_DEGRADED, exitOf("doctor")) + val report = out.toString() + assertTrue(report.contains("[warning] Plugin 'Terminal Tab' was stopped after repeated failures."), report) + assertTrue(report.contains(" Suggested: Restart BOSS."), report) + assertTrue(report.contains("1 problem found."), report) + assertTrue(report.contains("Not checked: browser"), report) + } + + @Test + fun `a finding whose message contains line breaks is printed on one line`() { + serve(MULTILINE) + + assertEquals(EXIT_DEGRADED, exitOf("doctor")) + val report = out.toString() + assertTrue(report.contains("[critical] The file could not be read. JSON input: [1,2"), report) + assertFalse(report.lines().any { it.startsWith("JSON input") }, report) + } + + @Test + fun `doctor json prints only the health object and still exits 2 when degraded`() { + serve(DEGRADED) + + assertEquals(EXIT_DEGRADED, exitOf("doctor", "--json")) + val printed = Json.parseToJsonElement(out.toString().trim()).jsonObject + assertEquals(true, printed["degraded"]?.jsonPrimitive?.booleanOrNull) + assertFalse(printed.containsKey("version"), "doctor --json must print the health object, not the status") + } + + @Test + fun `doctor exits 1 on stderr when the running BOSS returns no health report`() { + serve(NO_HEALTH) + + assertEquals(1, exitOf("doctor")) + assertEquals("", out.toString()) + assertTrue(err.toString().contains("did not return a health report"), err.toString()) + } + + @Test + fun `doctor exits 1 when BOSS is not running`() { + assertEquals(1, exitOf("doctor")) + assertTrue(err.toString().contains("BOSS is not running"), err.toString()) + } + + @Test + fun `status keeps exit 0 when degraded and points at doctor`() { + serve(DEGRADED) + + assertEquals(0, exitOf("status")) + assertTrue(out.toString().contains("Health: 1 problem (run 'boss doctor')"), out.toString()) + } + + @Test + fun `doctor qualifies an incomplete report and respects degraded without details`() { + serve("""{"running":true,"health":{"degraded":false,"findings":[],"unchecked":["plugins"]}}""") + assertEquals(0, exitOf("doctor")) + assertTrue(out.toString().contains("No problems found in checked areas.")) + + SingleInstanceManager.statusProviderOverride = { + """{"running":true,"health":{"degraded":true,"findings":[],"unchecked":[]}}""" + } + assertEquals(EXIT_DEGRADED, exitOf("doctor")) + assertTrue(out.toString().contains("BOSS reports degraded health without finding details.")) + } + + @Test + fun `status does not claim OK when health sources could not be checked`() { + serve("""{"running":true,"health":{"degraded":false,"findings":[],"unchecked":["plugins","browser","mcp"]}}""") + + assertEquals(0, exitOf("status")) + val report = out.toString() + assertTrue(report.contains("No problems found in checked areas; not checked: plugins, browser, mcp"), report) + assertFalse(report.contains("Health: OK"), report) + } + + @Test + fun `status prints no health line for a BOSS that does not report health`() { + serve(NO_HEALTH) + + assertEquals(0, exitOf("status")) + assertFalse(out.toString().contains("Health:"), out.toString()) + } + + @Test + fun `doctor names the partly covered area and does not call it problem free`() { + serve(PARTIAL) + + assertEquals(EXIT_DEGRADED, exitOf("doctor")) + val report = out.toString() + assertTrue(report.contains("[warning] Plugin 'Terminal Tab' was stopped after repeated failures."), report) + assertTrue(report.contains("Partially checked: plugins."), report) + assertTrue(report.contains("so problems there may be missing"), report) + } + + @Test + fun `doctor does not claim no problems found when an area was only partly read`() { + serve("""{"running":true,"health":{"degraded":false,"findings":[],"unchecked":[],"partial":["plugins"]}}""") + + assertEquals(0, exitOf("doctor")) + val report = out.toString() + assertTrue(report.contains("No problems found in checked areas."), report) + assertFalse(report.contains("No problems found."), report) + assertTrue(report.contains("Partially checked: plugins."), report) + } + + @Test + fun `status says which area was only partly checked`() { + serve(PARTIAL) + + assertEquals(0, exitOf("status")) + val report = out.toString() + assertTrue(report.contains("1 problem (run 'boss doctor'); partially checked: plugins"), report) + } + + @Test + fun `status reports both unchecked and partly checked areas`() { + serve( + """{"running":true,"health":{"degraded":false,"findings":[],""" + + """"unchecked":["browser"],"partial":["plugins"]}}""", + ) + + assertEquals(0, exitOf("status")) + val report = out.toString() + assertTrue( + report.contains("No problems found in checked areas; not checked: browser; partially checked: plugins"), + report, + ) + } + + @Test + fun `a BOSS that does not send partial is still read as fully checked`() { + serve(HEALTHY) + + assertEquals(0, exitOf("doctor")) + assertTrue(out.toString().contains("No problems found.")) + assertFalse(out.toString().contains("Partially checked"), out.toString()) + + assertEquals(0, exitOf("status")) + assertTrue(out.toString().contains("Health: OK"), out.toString()) + } + + @Test + fun `doctor is registered as a subcommand`() { + assertTrue(createBossCLI().registeredSubcommands().any { it.commandName == "doctor" }) + } + + private fun serve(status: String) { + SingleInstanceManager.statusProviderOverride = { status } + assertTrue(SingleInstanceManager.acquireLock()) + // The in-process server logs to stdout while it starts. Waiting for it to answer puts that + // logging behind us, so exitOf can capture only the command's own output. + assertTrue(SingleInstanceManager.queryStatus().isSuccess) + } + + /** Run the CLI and return its exit code, with [out] and [err] holding only its own output. */ + private fun exitOf(vararg args: String): Int { + out.reset() + err.reset() + return try { + createBossCLI().parse(args.toList()) + 0 + } catch (e: ProgramResult) { + e.statusCode + } + } + + private companion object { + const val NO_HEALTH = """{"running":true,"version":"9.5.12"}""" + + const val HEALTHY = """{"running":true,"health":{"degraded":false,"findings":[],"unchecked":[]}}""" + + val DEGRADED = + """ + {"running":true,"version":"9.5.12","health":{"degraded":true,"unchecked":["browser"],"findings":[ + {"area":"plugins","severity":"warning","code":"plugin_stopped_after_failures", + "summary":"Plugin 'Terminal Tab' was stopped after repeated failures.", + "subject":"terminaltab","remedy":"Restart BOSS."}]}} + """.trimIndent() + + // One window reported a stopped plugin while another window's source failed. + val PARTIAL = + """ + {"running":true,"health":{"degraded":true,"unchecked":[],"partial":["plugins"],"findings":[ + {"area":"plugins","severity":"warning","code":"plugin_stopped_after_failures", + "summary":"Plugin 'Terminal Tab' was stopped after repeated failures.", + "subject":"terminaltab","remedy":"Restart BOSS."}]}} + """.trimIndent() + + // The summary carries a JSON-escaped line break, as a parser error message does. + val MULTILINE = + """ + {"running":true,"health":{"degraded":true,"unchecked":[],"findings":[ + {"area":"mcp","severity":"critical","code":"mcp_tools_withheld", + "summary":"The file could not be read.\nJSON input: [1,2"}]}} + """.trimIndent() + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/ChromiumAutoDownloaderTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/ChromiumAutoDownloaderTest.kt index c54abd728e..452601961c 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/ChromiumAutoDownloaderTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/ChromiumAutoDownloaderTest.kt @@ -116,6 +116,33 @@ class ChromiumAutoDownloaderTest { assertFalse(backup.exists()) } + @Test + fun `read-only cache inspection leaves the startup repair attempt available`() { + target.mkdirs() + File(target, "executable.name").writeText("BOSS") + File(target, "version.txt").writeText("9.2.0") + val plist = File(target, "BOSS.app/Contents/Info.plist") + plist.parentFile.mkdirs() + plist.writeText("CFBundleURLTypes") + val marker = File(root, "boss-chromium.types-repair") + + fun inspect(recordRepair: () -> Unit) = + ChromiumAutoDownloader.chromiumInstalledAt( + dir = target.toPath(), + requiredVersion = "9.2.0", + isMac = true, + repairAttempted = { marker.exists() }, + recordRepair = recordRepair, + ) + + assertFalse(inspect {}) + assertFalse(inspect {}) + assertFalse(marker.exists(), "health inspection must not consume the repair budget") + assertFalse(inspect { marker.writeText("9.2.0") }, "startup must still schedule the repair") + assertTrue(marker.exists()) + assertTrue(inspect { error("the one-shot repair must not repeat") }) + } + // ---- installFromCandidates: source fallback + checksum verification ---- private fun candidate( diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthCollectorTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthCollectorTest.kt new file mode 100644 index 0000000000..5af1c41fb2 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthCollectorTest.kt @@ -0,0 +1,97 @@ +package ai.rever.boss.health + +import ai.rever.boss.components.plugin.PluginHealthRow +import ai.rever.boss.components.plugin.PluginHealthSnapshot +import ai.rever.boss.components.plugin.PluginHealthStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WorkspaceHealthCollectorTest { + @Test + fun `a source that throws is reported as unchecked while the others are still read`() { + val report = + WorkspaceHealthCollector( + pluginSnapshots = { error("manager already disposed") }, + browserHealth = { BrowserEngineHealth.Unresponsive }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + assertEquals(setOf(HealthArea.PLUGINS), report.unchecked) + assertTrue(report.partial.isEmpty(), "nothing was read, so the area is unchecked rather than partial") + assertEquals(listOf(HealthCodes.BROWSER_ENGINE_UNRESPONSIVE), report.findings.map { it.code }) + } + + @Test + fun `a class missing from a health path is contained and never reported as healthy`() { + val report = + WorkspaceHealthCollector( + pluginSnapshots = { PluginSnapshotRead(listOf(ONE_WINDOW)) }, + browserHealth = { throw NoClassDefFoundError("com/teamdev/jxbrowser/engine/Engine") }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + assertEquals(setOf(HealthArea.BROWSER), report.unchecked) + assertFalse(report.degraded) + } + + @Test + fun `with no window open plugin health is unchecked rather than healthy`() { + val report = + WorkspaceHealthCollector( + pluginSnapshots = { PluginSnapshotRead(emptyList()) }, + browserHealth = { BrowserEngineHealth.Healthy }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + assertEquals(setOf(HealthArea.PLUGINS), report.unchecked) + assertFalse(report.degraded) + } + + @Test + fun `snapshots that were read survive a failing source and the area is reported partial`() { + val report = + WorkspaceHealthCollector( + pluginSnapshots = { PluginSnapshotRead(listOf(STOPPED_WINDOW), failedSources = 1) }, + browserHealth = { BrowserEngineHealth.Healthy }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + assertEquals(listOf(HealthCodes.PLUGIN_STOPPED), report.findings.map { it.code }) + assertTrue(report.unchecked.isEmpty()) + assertEquals(setOf(HealthArea.PLUGINS), report.partial) + } + + @Test + fun `plugins are unchecked rather than partial when every window source failed`() { + val report = + WorkspaceHealthCollector( + pluginSnapshots = { PluginSnapshotRead(emptyList(), failedSources = 2) }, + browserHealth = { BrowserEngineHealth.Healthy }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + assertEquals(setOf(HealthArea.PLUGINS), report.unchecked) + assertTrue(report.partial.isEmpty()) + assertFalse(report.degraded) + } + + private companion object { + val ONE_WINDOW = PluginHealthSnapshot(rows = emptyList(), sandboxDisabledPluginIds = emptySet()) + + val STOPPED_WINDOW = + PluginHealthSnapshot( + rows = + listOf( + PluginHealthRow( + "terminaltab", + "Terminal Tab", + PluginHealthStatus.NEEDS_ATTENTION, + "The plugin stopped and needs recovery.", + ), + ), + sandboxDisabledPluginIds = setOf("terminaltab"), + ) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthReportTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthReportTest.kt new file mode 100644 index 0000000000..d9b6de09dc --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthReportTest.kt @@ -0,0 +1,283 @@ +package ai.rever.boss.health + +import ai.rever.boss.components.plugin.PluginHealthRow +import ai.rever.boss.components.plugin.PluginHealthSnapshot +import ai.rever.boss.components.plugin.PluginHealthStatus +import ai.rever.boss.mcp.McpKillSwitchFault +import ai.rever.boss.mcp.McpPolicyFault +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WorkspaceHealthReportTest { + @Test + fun `a workspace with nothing wrong is not degraded`() { + val report = workspaceHealthReport(inputs(plugins = listOf(snapshot(row("notes", PluginHealthStatus.HEALTHY))))) + + assertFalse(report.degraded) + assertTrue(report.findings.isEmpty()) + assertTrue(report.unchecked.isEmpty()) + } + + @Test + fun `a plugin stopped by the sandbox watchdog is one warning naming the plugin`() { + val stoppedRow = + row("terminaltab", PluginHealthStatus.NEEDS_ATTENTION, name = "Terminal Tab", detail = STOPPED) + + val report = + workspaceHealthReport(inputs(plugins = listOf(snapshot(stoppedRow, stopped = setOf("terminaltab"))))) + + val finding = report.findings.single() + assertEquals(HealthCodes.PLUGIN_STOPPED, finding.code) + assertEquals(HealthArea.PLUGINS, finding.area) + assertEquals(HealthSeverity.WARNING, finding.severity) + assertEquals("terminaltab", finding.subject) + assertEquals("Plugin 'Terminal Tab' was stopped after repeated failures.", finding.summary) + assertTrue(report.degraded) + } + + @Test + fun `a plugin needing attention carries the health center's own detail`() { + val gated = + row( + "notes", + PluginHealthStatus.NEEDS_ATTENTION, + name = "Notes", + detail = "This plugin requires a newer BOSS version.", + ) + + val finding = workspaceHealthReport(inputs(plugins = listOf(snapshot(gated)))).findings.single() + + assertEquals(HealthCodes.PLUGIN_NEEDS_ATTENTION, finding.code) + assertEquals("Plugin 'Notes': This plugin requires a newer BOSS version.", finding.summary) + assertEquals("notes", finding.subject) + } + + @Test + fun `plugins unavailable by choice or by access are not degradations`() { + val disabled = row("notes", PluginHealthStatus.UNAVAILABLE, detail = "The plugin is disabled.") + val restricted = row("admin", PluginHealthStatus.UNAVAILABLE, detail = "Access is required for this plugin.") + + val report = workspaceHealthReport(inputs(plugins = listOf(snapshot(disabled, restricted)))) + + assertFalse(report.degraded) + } + + @Test + fun `the same problem in two windows is reported once`() { + val crashedRow = row("notes", PluginHealthStatus.NEEDS_ATTENTION, detail = CRASHED) + val stoppedRow = row("tasks", PluginHealthStatus.NEEDS_ATTENTION, detail = STOPPED) + + val report = + workspaceHealthReport( + inputs( + plugins = + listOf( + snapshot(crashedRow, stoppedRow, stopped = setOf("tasks")), + snapshot(crashedRow, stoppedRow, stopped = setOf("tasks")), + ), + ), + ) + + assertEquals( + listOf(HealthCodes.PLUGIN_NEEDS_ATTENTION to "notes", HealthCodes.PLUGIN_STOPPED to "tasks"), + report.findings.map { it.code to it.subject }.sortedBy { it.second }, + ) + } + + @Test + fun `a browser engine that is not installed yet is a warning that passes on the engine's advice`() { + val advice = "BOSS-branded Chromium not found. Please restart the app to trigger auto-download." + + val finding = + workspaceHealthReport(inputs(browser = BrowserEngineHealth.NotInstalled(advice))).findings.single() + + assertEquals(HealthCodes.BROWSER_ENGINE_NOT_INSTALLED, finding.code) + assertEquals(HealthSeverity.WARNING, finding.severity) + assertEquals(advice, finding.remedy) + } + + @Test + fun `a browser engine that could not start is critical and carries the engine's reason`() { + val reason = "License validation failed. Please check your internet connection." + + val finding = + workspaceHealthReport(inputs(browser = BrowserEngineHealth.Unavailable(reason))).findings.single() + + assertEquals(HealthCodes.BROWSER_ENGINE_UNAVAILABLE, finding.code) + assertEquals(HealthSeverity.CRITICAL, finding.severity) + assertTrue(finding.summary.endsWith(reason), finding.summary) + assertNull(finding.subject) + } + + @Test + fun `a browser engine whose automatic recovery gave up is critical`() { + val finding = workspaceHealthReport(inputs(browser = BrowserEngineHealth.Unresponsive)).findings.single() + + assertEquals(HealthCodes.BROWSER_ENGINE_UNRESPONSIVE, finding.code) + assertEquals(HealthSeverity.CRITICAL, finding.severity) + } + + @Test + fun `an unreadable kill-switch file withholds every tool and is critical`() { + val fault = + McpKillSwitchFault.PersistedSetUnreadable("/boss/mcp-disabled-tools.json", null, 3, "unexpected end") + + val finding = mcpFinding(killSwitch = fault) + + assertEquals(HealthCodes.MCP_TOOLS_WITHHELD, finding.code) + assertEquals(HealthSeverity.CRITICAL, finding.severity) + assertEquals(fault.message, finding.summary) + } + + @Test + fun `a kill-switch toggle that could not be saved is a warning about that tool`() { + val fault = + McpKillSwitchFault.TogglePersistFailed( + toolName = "run_command", + enabled = true, + outcome = McpKillSwitchFault.ToggleOutcome.REFUSED, + error = "disk full", + ) + + val finding = mcpFinding(killSwitch = fault) + + assertEquals(HealthCodes.MCP_TOOL_SETTING_NOT_SAVED, finding.code) + assertEquals(HealthSeverity.WARNING, finding.severity) + assertEquals("run_command", finding.subject) + assertEquals(fault.message, finding.summary) + } + + @Test + fun `policy faults are critical only when the policy file cannot be read`() { + val unreadable = McpPolicyFault.PersistedPolicyUnreadable("/boss/mcp-tool-policy.json", "unexpected end") + val notSaved = McpPolicyFault.PolicyPersistFailed("run_command", "disk full") + + val unreadableFinding = mcpFinding(policy = unreadable) + val notSavedFinding = mcpFinding(policy = notSaved) + + assertEquals(HealthCodes.MCP_POLICY_UNREADABLE, unreadableFinding.code) + assertEquals(HealthSeverity.CRITICAL, unreadableFinding.severity) + assertEquals(HealthCodes.MCP_POLICY_NOT_SAVED, notSavedFinding.code) + assertEquals(HealthSeverity.WARNING, notSavedFinding.severity) + assertEquals("run_command", notSavedFinding.subject) + } + + @Test + fun `sources that could not be read are unchecked, never healthy`() { + val report = workspaceHealthReport(WorkspaceHealthInputs(plugins = null, browser = null, mcp = null)) + + assertEquals(HealthArea.entries.toSet(), report.unchecked) + assertFalse(report.degraded) + } + + @Test + fun `critical findings are listed before warnings`() { + val stoppedRow = row("tasks", PluginHealthStatus.NEEDS_ATTENTION, detail = STOPPED) + + val report = + workspaceHealthReport( + inputs( + plugins = listOf(snapshot(stoppedRow, stopped = setOf("tasks"))), + browser = BrowserEngineHealth.Unresponsive, + ), + ) + + assertEquals(listOf(HealthSeverity.CRITICAL, HealthSeverity.WARNING), report.findings.map { it.severity }) + } + + @Test + fun `a failed window source marks plugins partial while keeping what the others reported`() { + val stoppedRow = row("tasks", PluginHealthStatus.NEEDS_ATTENTION, detail = STOPPED) + + val report = + workspaceHealthReport( + inputs(plugins = listOf(snapshot(stoppedRow, stopped = setOf("tasks"))), pluginSourceFailures = 1), + ) + + assertEquals(listOf(HealthCodes.PLUGIN_STOPPED), report.findings.map { it.code }) + assertEquals(setOf(HealthArea.PLUGINS), report.partial) + assertTrue(report.unchecked.isEmpty()) + } + + @Test + fun `an area nothing could be read from is unchecked and never also partial`() { + val report = workspaceHealthReport(inputs(plugins = null, pluginSourceFailures = 2)) + + assertEquals(setOf(HealthArea.PLUGINS), report.unchecked) + assertTrue(report.partial.isEmpty()) + } + + @Test + fun `a fully read area is neither unchecked nor partial`() { + val report = workspaceHealthReport(inputs(plugins = listOf(snapshot(row("notes", PluginHealthStatus.HEALTHY))))) + + assertTrue(report.unchecked.isEmpty()) + assertTrue(report.partial.isEmpty()) + } + + @Test + fun `json uses lowercase wire names and omits absent fields`() { + val report = + WorkspaceHealthReport( + findings = + listOf(HealthFinding(HealthArea.MCP, HealthSeverity.CRITICAL, "mcp_tools_withheld", "Withheld.")), + unchecked = setOf(HealthArea.BROWSER), + ) + + val json = report.toJson() + + assertTrue(json.getValue("degraded").jsonPrimitive.boolean) + val finding = (json.getValue("findings") as JsonArray).single().jsonObject + assertEquals("mcp", finding.getValue("area").jsonPrimitive.content) + assertEquals("critical", finding.getValue("severity").jsonPrimitive.content) + assertEquals("mcp_tools_withheld", finding.getValue("code").jsonPrimitive.content) + assertFalse(finding.containsKey("subject")) + assertFalse(finding.containsKey("remedy")) + assertEquals(listOf("browser"), (json.getValue("unchecked") as JsonArray).map { it.jsonPrimitive.content }) + assertTrue((json.getValue("partial") as JsonArray).isEmpty()) + } + + @Test + fun `json carries partial areas by wire name`() { + val json = WorkspaceHealthReport(findings = emptyList(), partial = setOf(HealthArea.PLUGINS)).toJson() + + assertEquals(listOf("plugins"), (json.getValue("partial") as JsonArray).map { it.jsonPrimitive.content }) + assertFalse(json.getValue("degraded").jsonPrimitive.boolean) + } + + private fun inputs( + plugins: List? = emptyList(), + browser: BrowserEngineHealth? = BrowserEngineHealth.Healthy, + mcp: McpFaults? = McpFaults(killSwitch = null, policy = null), + pluginSourceFailures: Int = 0, + ) = WorkspaceHealthInputs(plugins, browser, mcp, pluginSourceFailures) + + private fun mcpFinding( + killSwitch: McpKillSwitchFault? = null, + policy: McpPolicyFault? = null, + ): HealthFinding = workspaceHealthReport(inputs(mcp = McpFaults(killSwitch, policy))).findings.single() + + private fun snapshot( + vararg rows: PluginHealthRow, + stopped: Set = emptySet(), + ) = PluginHealthSnapshot(rows.toList(), stopped) + + private fun row( + id: String, + status: PluginHealthStatus, + name: String = id, + detail: String = "Running normally.", + ) = PluginHealthRow(id, name, status, detail) + + private companion object { + const val STOPPED = "The plugin stopped and needs recovery." + const val CRASHED = "The plugin needs recovery after an unexpected failure." + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthSourcesTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthSourcesTest.kt new file mode 100644 index 0000000000..b9b2a13ffe --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthSourcesTest.kt @@ -0,0 +1,94 @@ +package ai.rever.boss.health + +import ai.rever.boss.components.plugin.PluginHealthRow +import ai.rever.boss.components.plugin.PluginHealthSnapshot +import ai.rever.boss.components.plugin.PluginHealthStatus +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WorkspaceHealthSourcesTest { + @BeforeTest + fun setUp() { + WorkspaceHealthSources.clear() + } + + @AfterTest + fun tearDown() { + WorkspaceHealthSources.clear() + } + + @Test + fun `every registered window contributes a snapshot`() { + WorkspaceHealthSources.registerPlugins("window-a") { snapshotOf("notes") } + WorkspaceHealthSources.registerPlugins("window-b") { snapshotOf("tasks") } + + assertEquals(setOf("notes", "tasks"), pluginIds()) + } + + @Test + fun `unregistering a replaced source keeps the registration that replaced it`() { + val first: () -> PluginHealthSnapshot = { snapshotOf("first") } + val second: () -> PluginHealthSnapshot = { snapshotOf("second") } + WorkspaceHealthSources.registerPlugins("window", first) + WorkspaceHealthSources.registerPlugins("window", second) + + WorkspaceHealthSources.unregisterPlugins("window", first) + assertEquals(setOf("second"), pluginIds()) + + WorkspaceHealthSources.unregisterPlugins("window", second) + assertTrue(WorkspaceHealthSources.pluginSnapshots().snapshots.isEmpty()) + } + + @Test + fun `a window source that throws is counted while every other window is still read`() { + WorkspaceHealthSources.registerPlugins("window-a") { snapshotOf("notes") } + WorkspaceHealthSources.registerPlugins("window-b") { error("manager already disposed") } + WorkspaceHealthSources.registerPlugins("window-c") { throw NoClassDefFoundError("ai/rever/boss/Plugin") } + + val read = WorkspaceHealthSources.pluginSnapshots() + + assertEquals(setOf("notes"), read.snapshots.flatMap { s -> s.rows.map { it.pluginId } }.toSet()) + assertEquals(2, read.failedSources) + } + + @Test + fun `a failing window source does not hide a stopped plugin in another window`() { + WorkspaceHealthSources.registerPlugins("window-a") { snapshotOf("terminaltab", stopped = true) } + WorkspaceHealthSources.registerPlugins("window-b") { error("manager already disposed") } + + val report = + WorkspaceHealthCollector( + pluginSnapshots = WorkspaceHealthSources::pluginSnapshots, + browserHealth = { BrowserEngineHealth.Healthy }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + val finding = report.findings.single() + assertEquals(HealthCodes.PLUGIN_STOPPED, finding.code) + assertEquals("terminaltab", finding.subject) + assertTrue(report.unchecked.isEmpty(), "the window that answered was read, so plugins is not unchecked") + assertEquals(setOf(HealthArea.PLUGINS), report.partial, "partial coverage must be declared, not hidden") + } + + private fun pluginIds(): Set = + WorkspaceHealthSources + .pluginSnapshots() + .snapshots + .flatMap { snapshot -> snapshot.rows.map { it.pluginId } } + .toSet() + + private fun snapshotOf( + pluginId: String, + stopped: Boolean = false, + ): PluginHealthSnapshot { + val status = if (stopped) PluginHealthStatus.NEEDS_ATTENTION else PluginHealthStatus.HEALTHY + val detail = if (stopped) "The plugin stopped and needs recovery." else "Running normally." + return PluginHealthSnapshot( + rows = listOf(PluginHealthRow(pluginId, pluginId, status, detail)), + sandboxDisabledPluginIds = if (stopped) setOf(pluginId) else emptySet(), + ) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/CliBootstrapTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/CliBootstrapTest.kt index bf77e11cd2..5ebb3cb729 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/CliBootstrapTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/CliBootstrapTest.kt @@ -10,6 +10,7 @@ class CliBootstrapTest { fun isHeadlessCliRecognizesStandardCommands() { assertTrue(CliBootstrap.isHeadlessCli(arrayOf("status"))) assertTrue(CliBootstrap.isHeadlessCli(arrayOf("STATUS"))) + assertTrue(CliBootstrap.isHeadlessCli(arrayOf("doctor"))) assertTrue(CliBootstrap.isHeadlessCli(arrayOf("mcp"))) assertTrue(CliBootstrap.isHeadlessCli(arrayOf("completion"))) assertTrue(CliBootstrap.isHeadlessCli(arrayOf("--help"))) diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/OsOpenArgumentsTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/OsOpenArgumentsTest.kt index 1d72ffc397..ffb3469d6a 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/OsOpenArgumentsTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/OsOpenArgumentsTest.kt @@ -1,10 +1,10 @@ package ai.rever.boss.utils +import ai.rever.boss.cli.createBossCLI import java.io.File import java.net.URLDecoder import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull import kotlin.test.assertTrue /** @@ -224,27 +224,11 @@ class OsOpenArgumentsTest { @Test fun `the CLI subcommand list matches the CLI`() { // The failure mode when these drift is a double-open, which is easy to - // miss and hard to attribute - so it is pinned against the source that - // registers them. - val root = - assertNotNull( - generateSequence(File("").absoluteFile) { it.parentFile } - .firstOrNull { File(it, "composeApp/build.gradle.kts").isFile }, - "could not locate the repository root", - ) - val source = File(root, "composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt") - assertTrue(source.isFile, "BossCommand.kt not found at ${source.absolutePath}") - - // The negative lookbehind excludes the root command, which is declared - // as `NoOpCliktCommand(name = "boss")` and whose text contains - // `CliktCommand(name = "boss")` as a substring. Without it the test - // demands a "boss" subcommand that does not exist. - val registered = - Regex("""(?` | Loads a workspace configuration | `boss workspace ./workspace.json` | | `boss terminal` | Opens a new integrated BossTerm pane | `boss terminal` | | `boss status` | Checks running BossConsole health and status | `boss status --json` | +| `boss doctor` | Reports health problems with suggested next steps (exit `2` when degraded) | `boss doctor --json` | | `boss mcp ` | Discovers and invokes MCP tools | `boss mcp list` | | `boss completion ` | Generates shell tab-completion scripts | `boss completion bash > ~/.boss-complete.sh` | @@ -63,6 +64,47 @@ boss status --json } ``` +#### Health: the `health` field and `boss doctor` + +`boss status --json` also carries a `health` object. It reports problems BOSS already tracks but previously showed only inside a window: plugins the sandbox watchdog stopped after repeated failures, plugins that need attention in **Help > Plugin Health & Recovery**, a browser engine that is not installed, could not start, or has stopped responding, and MCP kill-switch or policy faults that withhold tools. The field is additive and `boss status` keeps its existing exit codes; its human output gains a single `Health:` line. + +`boss doctor` prints the same report with a suggested next step for each problem, and exits with code `2` while any problem is present. Both commands are read-only: they never change plugin, browser or MCP state. Both also say in words when coverage was incomplete: `Not checked:` for an area nothing could be read from, and `Partially checked:` for one where some sources answered and others failed. + +```bash +# Human-readable report: exit 0 when healthy, 2 when degraded, 1 when BOSS is not running +boss doctor + +# The health object as JSON, with the same exit codes +boss doctor --json +``` + +**Health Object Example**: +```json +{ + "degraded": true, + "findings": [ + { + "area": "plugins", + "severity": "warning", + "code": "plugin_stopped_after_failures", + "summary": "Plugin 'Terminal Tab' was stopped after repeated failures.", + "subject": "ai.rever.boss.plugin.dynamic.terminaltab", + "remedy": "Reload it from Help > Plugin Health & Recovery in the affected window, or restart BOSS." + } + ], + "unchecked": [], + "partial": [] +} +``` + +- `area` is `plugins`, `browser` or `mcp`. +- `severity` is `critical` when something that should work is broken and a whole capability is gone (a browser engine that failed, or every MCP tool), and `warning` for something narrower: one plugin, one setting that was not saved, or a browser engine that has not been downloaded yet. Critical findings are listed first. +- `code` is stable for scripts to match on: `plugin_stopped_after_failures`, `plugin_needs_attention`, `browser_engine_not_installed`, `browser_engine_unavailable`, `browser_engine_unresponsive`, `mcp_tools_withheld`, `mcp_tool_setting_not_saved`, `mcp_policy_unreadable`, `mcp_policy_not_saved`. `summary` is for people and may be reworded. +- `subject` (a plugin id or tool name) and `remedy` are omitted when they do not apply. +- `unchecked` lists areas whose state could not be read at all, including `plugins` while no BOSS window is open. They are neither healthy nor degraded and do not affect the exit code. +- `partial` lists areas that were read from several sources where some of those sources failed. Today only `plugins` can appear, because it has one source per open window: if one window's source fails, the other windows' findings are still reported and `plugins` is listed here instead of being dropped into `unchecked`. Findings in a partial area are real, but the area is not fully covered, so an empty result there is not a clean bill of health. Like `unchecked`, it does not affect the exit code. An area is never in both `unchecked` and `partial`. The field is additive: a BOSS that predates it simply omits it, and both `boss status` and `boss doctor` treat an absent `partial` as empty. +- Plugin health covers every open window that could be read. A plugin you disabled, or one your role cannot access, is not reported as a problem. + --- ### 2. `boss mcp list` @@ -159,6 +201,7 @@ The CLI adheres to strict UNIX process exit codes and standard stream separation - **Exit Code `0`**: Operation succeeded. `stdout` contains the tool output or JSON response. - **Exit Code `1`**: Tool execution failed (`isError == true`), invalid tool arguments, or desktop app offline. Clikt usage errors also use exit code `1`. The error description is written strictly to `stderr`, leaving `stdout` clean so shell pipelines do not ingest corrupted data. +- **Exit Code `2`**: `boss doctor` only. BOSS is running but reported at least one health finding. `stdout` still contains the report, so a script can branch on the code and read the details. `boss status` never uses this code. ### Offline Fail-Fast If BossConsole is not running, commands fail immediately without hanging: diff --git a/docs/mcp-agentless-operators.md b/docs/mcp-agentless-operators.md index 2e7c4d6c80..1a787aa9a9 100644 --- a/docs/mcp-agentless-operators.md +++ b/docs/mcp-agentless-operators.md @@ -19,6 +19,11 @@ shared across windows, while Toolbox opens only in the window where you selected Persisted disables live in the app data file `mcp-disabled-tools.json`. Fail-closed faults surface in the bottom status bar (`McpKillSwitchFault`). +These faults, and MCP policy faults, are also reported without a GUI. `boss doctor` prints each +one with a suggested next step and exits with code `2` while any is present, and +`boss status --json` carries the same data in its `health` field. Neither command changes +anything. See [CLI.md](CLI.md#health-the-health-field-and-boss-doctor). + ## Minimum attach path (for agents) 1. Sign in to BOSS. diff --git a/scripts/boss b/scripts/boss index 8f37e62bef..8bccfe4080 100755 --- a/scripts/boss +++ b/scripts/boss @@ -186,7 +186,7 @@ case "$1" in open_url "$deep_link" ;; - status|mcp|completion) + status|doctor|mcp|completion) BOSS_BIN="${BOSS_BIN:-}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [ -z "$BOSS_BIN" ]; then @@ -234,6 +234,7 @@ Usage: Commands: status Queries status and health of the running BOSS instance + doctor Reports problems in the running BOSS instance (exit 2 when degraded) mcp [args] Discovers and invokes desktop MCP tools (list, describe, invoke) completion Generates shell completion script (bash, zsh, fish) url Opens a URL in Fluck browser diff --git a/scripts/boss.bat b/scripts/boss.bat index 770ea828fc..4779cffe7a 100644 --- a/scripts/boss.bat +++ b/scripts/boss.bat @@ -26,6 +26,7 @@ REM Parse command set "COMMAND=%~1" if /i "%COMMAND%"=="status" goto :cmd_forward_exe +if /i "%COMMAND%"=="doctor" goto :cmd_forward_exe if /i "%COMMAND%"=="mcp" goto :cmd_forward_exe if /i "%COMMAND%"=="completion" goto :cmd_forward_exe if /i "%COMMAND%"=="url" goto :cmd_url @@ -145,6 +146,7 @@ echo boss ^ [arguments] Run explicit command echo. echo Commands: echo status Queries status and health of the running BOSS instance +echo doctor Reports problems in the running BOSS instance (exit 2 when degraded) echo mcp ^ [args] Discovers and invokes desktop MCP tools (list, describe, invoke) echo completion ^ Generates shell completion script (bash, zsh, fish) echo url ^ Opens a URL in Fluck browser diff --git a/scripts/boss.ps1 b/scripts/boss.ps1 index 75de3b96c2..01288737a2 100644 --- a/scripts/boss.ps1 +++ b/scripts/boss.ps1 @@ -128,6 +128,7 @@ function Show-Help { Write-Host "" Write-Host "Commands:" Write-Host " status Queries status and health of the running BOSS instance" + Write-Host " doctor Reports problems in the running BOSS instance (exit 2 when degraded)" Write-Host " mcp [args] Discovers and invokes desktop MCP tools (list, describe, invoke)" Write-Host " completion Generates shell completion script (bash, zsh, fish)" Write-Host " url Opens a URL in Fluck browser" @@ -237,7 +238,7 @@ switch ($Command.ToLower()) { Open-BossDeepLink $deepLink } - { $_ -in "status", "mcp", "completion" } { + { $_ -in "status", "doctor", "mcp", "completion" } { $bossExe = $env:BOSS_EXE if ($bossExe -and -not (Test-Path $bossExe -PathType Leaf)) { [Console]::Error.WriteLine("Error: BOSS_EXE does not name an executable file.") From 341010c8c2a87cfaf4b3a3e268e8d7b68a446df3 Mon Sep 17 00:00:00 2001 From: Vidhaankhare16 <142911723+Vidhaankhare16@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:09:49 +0530 Subject: [PATCH 34/49] fix(cli): write piped CLI output as UTF-8 (#604) Co-authored-by: Shivang --- .../rever/boss/cli/HeadlessOutputEncoding.kt | 38 ++++++ .../ai/rever/boss/startup/CliBootstrap.kt | 2 + .../boss/cli/HeadlessOutputEncodingTest.kt | 117 ++++++++++++++++++ docs/CLI.md | 1 + 4 files changed, 158 insertions(+) create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/HeadlessOutputEncoding.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/HeadlessOutputEncodingTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/HeadlessOutputEncoding.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/HeadlessOutputEncoding.kt new file mode 100644 index 0000000000..93cf1b4b4a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/HeadlessOutputEncoding.kt @@ -0,0 +1,38 @@ +package ai.rever.boss.cli + +import java.io.FileDescriptor +import java.io.FileOutputStream +import java.io.OutputStream +import java.io.PrintStream +import java.nio.charset.StandardCharsets + +/** + * Makes headless CLI output UTF-8 wherever it is not a Windows console. + * + * JDK 17, the runtime BOSS is built and shipped with, encodes `System.out` and `System.err` with the + * platform default charset unless the stream is a Windows console. On Windows that default is the ANSI + * code page, so output read by an agent, a pipe or a file loses every character outside it: an arrow is + * written as `?`, and an accented letter as a byte that is not valid UTF-8. That covers `boss mcp invoke` + * tool output and every `--json` response, and no reader can repair a `?` afterwards. + * + * A Windows console is left alone. The JDK already encodes for the console's code page there, and UTF-8 + * bytes would be drawn as the wrong characters. The JDK marks those streams by setting + * `sun.stdout.encoding` and `sun.stderr.encoding`, which it does for a console and nothing else. + * + * The replacement streams have no buffer below the encoder, so nothing is left unwritten when the CLI + * calls `exitProcess`. + */ +internal fun configureHeadlessOutputEncoding( + stdoutConsoleEncoding: String? = System.getProperty("sun.stdout.encoding"), + stderrConsoleEncoding: String? = System.getProperty("sun.stderr.encoding"), + stdout: () -> OutputStream = { FileOutputStream(FileDescriptor.out) }, + stderr: () -> OutputStream = { FileOutputStream(FileDescriptor.err) }, +) { + if (writesUtf8(stdoutConsoleEncoding)) System.setOut(utf8PrintStream(stdout())) + if (writesUtf8(stderrConsoleEncoding)) System.setErr(utf8PrintStream(stderr())) +} + +/** Whether a standard stream gets UTF-8: only when the JVM did not mark it as a console. */ +internal fun writesUtf8(consoleEncoding: String?): Boolean = consoleEncoding.isNullOrBlank() + +internal fun utf8PrintStream(out: OutputStream): PrintStream = PrintStream(out, true, StandardCharsets.UTF_8) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt index 8099b4e2bf..7aa6916707 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt @@ -1,6 +1,7 @@ package ai.rever.boss.startup import ai.rever.boss.cli.configureHeadlessLogging +import ai.rever.boss.cli.configureHeadlessOutputEncoding import ai.rever.boss.cli.createBossCLI import ai.rever.boss.llm.RisaLlmTokenCommand import ai.rever.boss.utils.DeepLinkHandler @@ -62,6 +63,7 @@ object CliBootstrap { // Headless CLI commands (status, doctor, mcp, completion, --help) target the running // instance or generate output headlessly. isHeadlessCli(args) -> { + configureHeadlessOutputEncoding() configureHeadlessLogging() try { createBossCLI().main(args) diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/HeadlessOutputEncodingTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/HeadlessOutputEncodingTest.kt new file mode 100644 index 0000000000..01382f5190 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/HeadlessOutputEncodingTest.kt @@ -0,0 +1,117 @@ +package ai.rever.boss.cli + +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.URLClassLoader +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * RIGHTWARDS ARROW, which neither Cp1252 nor US-ASCII can encode. Built from its code point to keep + * this file ASCII. + */ +private val ARROW = Char(0x2192).toString() + +/** An arrow, and LATIN SMALL LETTER E WITH ACUTE, which Cp1252 and UTF-8 encode differently. */ +internal val ENCODING_PROBE_TEXT = "arrow $ARROW e-acute ${Char(0xE9)}" + +class HeadlessOutputEncodingTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `only a stream the JVM did not mark as a console is switched to UTF-8`() { + assertTrue(writesUtf8(null)) + assertTrue(writesUtf8("")) + assertFalse(writesUtf8("Cp437")) + assertFalse(writesUtf8("UTF-8")) + } + + @Test + fun `a redirected stream is replaced with UTF-8 and a console stream is left as it is`() { + val originalOut = System.out + val originalErr = System.err + val captured = ByteArrayOutputStream() + try { + configureHeadlessOutputEncoding( + stdoutConsoleEncoding = null, + stderrConsoleEncoding = "Cp437", + stdout = { captured }, + stderr = { error("a console stream must not be replaced") }, + ) + System.out.print(ARROW) + + assertContentEquals(ARROW.toByteArray(StandardCharsets.UTF_8), captured.toByteArray()) + assertSame(originalErr, System.err) + } finally { + System.setOut(originalOut) + System.setErr(originalErr) + } + } + + @Test + fun `piped CLI output is UTF-8 even when the JVM default charset cannot encode it`() { + val output = runProbe(configure = true) + + assertEquals(ENCODING_PROBE_TEXT, String(output, StandardCharsets.UTF_8).trim()) + } + + @Test + fun `without the fix the same piped output loses the text, so the test above can fail`() { + val output = runProbe(configure = false) + + assertFalse(String(output, StandardCharsets.UTF_8).contains(ENCODING_PROBE_TEXT)) + assertTrue(String(output, StandardCharsets.US_ASCII).contains("arrow ? e-acute ?")) + } + + /** + * Runs [HeadlessOutputEncodingProbe] in a child JVM whose stdout is a pipe and whose default charset is + * US-ASCII, so the result does not depend on the platform running the test. Launched the way + * [HeadlessLoggingTest] launches its probe, for the same Windows command-line limit. + */ + private fun runProbe(configure: Boolean): ByteArray { + val urls = + generateSequence(javaClass.classLoader) { it.parent } + .filterIsInstance() + .flatMap { it.getURLs().asSequence() } + .map { File(it.toURI()).path } + .toList() + val classpath = + (urls + System.getProperty("java.class.path").split(File.pathSeparator)) + .distinct() + .joinToString(File.pathSeparator) + val java = File(System.getProperty("java.home"), "bin/java").absolutePath + val argumentsFile = tempDir.resolve("encoding probe.args") + val quotedClasspath = classpath.replace("\\", "\\\\").replace("\"", "\\\"") + val mainClass = HeadlessOutputEncodingProbe::class.java.name + val mode = if (configure) "configure" else "control" + Files.writeString(argumentsFile, "-Dfile.encoding=US-ASCII\n-cp\n\"$quotedClasspath\"\n$mainClass\n$mode\n") + val process = ProcessBuilder(java, "@${argumentsFile.toAbsolutePath()}").start() + try { + val output = process.inputStream.readBytes() + assertTrue(process.waitFor(20, TimeUnit.SECONDS), "encoding probe must finish") + assertEquals(0, process.exitValue(), String(process.errorStream.readBytes())) + return output + } finally { + if (process.isAlive) process.destroyForcibly() + } + } +} + +/** Prints non-ASCII text the way a headless CLI command would; never starts the application. */ +object HeadlessOutputEncodingProbe { + @JvmStatic + fun main(args: Array) { + if (args.firstOrNull() == "configure") configureHeadlessOutputEncoding() + println(ENCODING_PROBE_TEXT) + } +} diff --git a/docs/CLI.md b/docs/CLI.md index 7d092a7fe7..feb4340821 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -201,6 +201,7 @@ The CLI adheres to strict UNIX process exit codes and standard stream separation - **Exit Code `0`**: Operation succeeded. `stdout` contains the tool output or JSON response. - **Exit Code `1`**: Tool execution failed (`isError == true`), invalid tool arguments, or desktop app offline. Clikt usage errors also use exit code `1`. The error description is written strictly to `stderr`, leaving `stdout` clean so shell pipelines do not ingest corrupted data. +- **Output encoding**: Piped or redirected output, including every `--json` response and `boss mcp invoke` tool output, is UTF-8 on every platform; a Windows console keeps its own code page. Windows PowerShell 5.1 decodes a native command's output with `[Console]::OutputEncoding`, so set it to UTF-8 (`[Console]::OutputEncoding = [System.Text.Encoding]::UTF8`) before capturing output that contains non-ASCII text. - **Exit Code `2`**: `boss doctor` only. BOSS is running but reported at least one health finding. `stdout` still contains the report, so a script can branch on the code and read the details. `boss status` never uses this code. ### Offline Fail-Fast From 283b450d74809db8c5bf8cdbb867be6d848e7e04 Mon Sep 17 00:00:00 2001 From: Kanak Khandelwal <152528927+Kanakk23@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:12:33 +0530 Subject: [PATCH 35/49] feat(cli): add plugin init, validate, and link commands with dev hot-reload (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Do not fail legacy bindings that the matcher now resolves * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Reject empty shortcut names without rejecting the space alias * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Keep invalid-key diagnostic outside the tester object function limit * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Apply tester formatting and wrap the invalid-key diagnostic * Preserve malformed native codes instead of storing invalid Unicode * Use required multiline when-branch formatting * Cover the new dev chrome-density shortcut in capture round trips * Fix API guard false positives for private lambda accessors --------- Co-authored-by: Claude Opus 5 Co-authored-by: AdityaK-iiita Co-authored-by: Antriksh1984 Co-authored-by: Shivang * docs: the keymap's two matchers, and the key names a hand-edited file is read against (#377) * docs: the keymap's two matchers, and the key names a hand-edited file is read against KEYBOARD_SHORTCUTS.md documents keymap-settings.json as hand-editable and then says nothing about what a "key" value may contain. It also describes an event flow that leaves out the component doing most of the dispatching. Three gaps, all of them things a reader currently has to find in the source: - `AWTKeyboardInterceptor` appeared once in the whole document, in a late section about plugin surfaces, and not in the event flow or the component list. It is the KeyEventDispatcher on the KeyboardFocusManager, which is what makes shortcuts work at all while a terminal or a browser holds focus - BossTerm consumes every key it is given, and the browser surface is heavyweight. - There are two matchers reading one keymap from two different name sources, and the document described neither the second one nor the consequence. A chord that works normally but reports no match in the Shortcuts tester is that disagreement, and there was nothing to read that would say so. - No list of accepted key names existed, so anyone hand-editing the file was guessing. "Left", "DirectionLeft", "ArrowLeft" and the arrow glyph are one key, and so are "-" and "Minus". The Key.toString() table is measured rather than described: the same key renders a word while the AWT toolkit is cold and a glyph once it is up, which is the detail that makes a hand-written comparison against a key name unsafe. Troubleshooting gains the two questions the document could not previously answer: a shortcut you rebound yourself doing nothing, and a shortcut that works in one place but not another. No behaviour change; documentation only. Co-Authored-By: Claude Opus 5 * Clarify static shortcut testing and legacy key repair documentation * Address review of version-dependent shortcut troubleshooting * Fix API guard false positives for private lambda accessors --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * feat(cli): add headless CLI agent MCP harness and IPC bridge (#349) * feat(cli): headless CLI agent MCP harness and IPC bridge - Add boss status and boss mcp (list, describe, invoke) subcommands - Add single-line Base64 wire protocol over authenticated loopback IPC - Add fast-path CLI execution before GUI bootstrap (<100ms) - Suppress dev-mode realtime websocket reconnect storm - Add 65 passing unit tests and comprehensive docs in docs/CLI.md * style(cli): resolve ktlint formatting and detekt static analysis violations * fix(cli): preserve MCP denials and harden headless IPC framing * fix(cli): isolate headless diagnostics and plugin schema failures * test(cli): avoid Windows subprocess command-line limit * fix(test): exclude private synthetic accessors from API contract --------- Co-authored-by: Shivang * feat(mcp): add runtime safety and human approval for MCP actions (#336) * feat(mcp): add Agent Tool Sandbox with human approval * fix(mcp): close shell approval bypass and serialize prompt lifecycle * fix(mcp): cover infrastructure mutations and preserve approval focus --------- Co-authored-by: Shivang * fix(plugins): bound signature mismatch retries and protect sidecar files (#401) * Make the sidecar backfill actually run, and bound what it costs when it can't The backfill added in #101 for already-installed system plugins usually did nothing, silently, and where it could never succeed it retried forever. Both halves of BossConsole#108. It usually did nothing, for two independent reasons. The drain was one-shot, triggered by the first non-null token from the sessionStatus collector in initialize(). The only path that enqueues runs from ensureSystemPluginsInstalled, which at startup is reached lazily on first DynamicPluginManager access, behind copyBundledPluginsToPluginDir and a JAR scan. A returning user with a session restored from local storage can emit Authenticated first, so the drain polled an empty queue, burned a process-global flag, and nothing was ever backfilled. Nothing logged either, so it looked exactly like success. Enqueues from the realtime manifest path arrived after the only trigger had already fired and could never be drained at all. And queued items were dropped rather than deferred. stillNeedsSidecar skipped any plugin with an update check in flight, but scheduleBackgroundUpdateCheck raises that flag synchronously one line before backfillSidecarIfMissing runs, so that described nearly every entry rather than a rare few. poll() had already removed them. maybeDrainSidecarBackfill replaces both: a Mutex-guarded drain that is safe to call from anywhere, triggered by the enqueue, the token handler, and the update check's own finally. In-flight entries are re-queued instead of discarded, and the per-pluginId attempted set replaces the one-shot AtomicBoolean as what bounds the work. That repeatability needs a bound of its own, because getDownloadUrl is not read-only: it books a plugin_downloads row, which feeds the store's default sortBy = "downloads" ranking. The attempted set bounds it within a process. For the genuine-mismatch case, which by design never resolves and so retried on every launch forever, fetchStoreSignature now separates a settled answer from an unsettled one and a .nosig marker records the settled one. It is keyed on the full pluginId|version|sha256 anchor, so it self-invalidates the moment any of the three changes, and PluginSignatureSidecar.delete removes it with the sidecar so no call site gains a cleanup obligation. The signature-only store route that would remove the cost rather than remember it is an edge-function change and is not in scope here. Also from the same review: - downloadOnly plugins are no longer signed on the download path. The microkernel runtime is the only one, PluginLoaderDelegateImpl refuses to install it as a plugin so it never reaches the loader's signature check, and signing it spent a getDownloadUrl and logged a warn every launch for a JAR that most likely has no store row. This matches backfillSidecarIfMissing, which was already only called for non-downloadOnly plugins. - Two sidecar deletes are now gated on the JAR delete having succeeded, like the download path's sweep already was. In the bundled-copy sweep that is a real signed-to-unsigned flip whenever the JAR is held open. - The second full SHA-256 pass over the JAR is replaced by a size and mtime stamp taken beside the first hash. Both replacement paths change mtime, and this one runs at startup over JARs as large as editor-tab and fluck-browser. The millisecond-resolution blind spot and the newly stricter treatment of a same-version re-download are both documented and pinned by tests. - PluginSignatureSidecar.write stages through a unique temp file. Two writers racing one JAR shared a fixed .sig.tmp and could interleave into a half-written sidecar, which is a present-but-invalid signature and so a harder failure than the missing one it was replacing. ConcurrentSidecarWriteTest fails on the old code and passes on the new. Co-Authored-By: Claude Opus 5 * fix(plugins): defer lifecycle to #347 and retain digest revalidation * test(plugins): propagate concurrent sidecar reader failures * style(tests): format concurrent writer futures * fix(plugins): keep cached mismatch visible and retry under enforcement * Read a sidecar without an exists() pre-check, so a concurrent write cannot throw The Windows CI leg failed ConcurrentSidecarWriteTest with FileNotFoundException out of PluginSignatureSidecar.read. The test is doing its job: the defect is in read, not in the test. read did `if (f.exists()) f.readText()`, which is a time-of-check-to-time-of-use race against write. Files.move with REPLACE_EXISTING transiently unlinks the target on Windows, so a reader that passed exists() could reach readText() after the unlink and throw out of a function every caller treats as total. Callers include the load path, so on Windows a plugin load overlapping a background sidecar write could fail for a reason that had nothing to do with the plugin. Reading straight through runCatching removes the window: an absent file and a mid-replace file both answer null, which is what the pre-check was trying to express. Every failure now reads as "no sidecar", which is the safe direction and matches the policy stated elsewhere in this file: a MISSING sidecar is warn-and-allow, while a present-but-invalid one hard-fails at load. It grants nobody anything new, since anyone able to make a sidecar unreadable could delete it instead. Co-Authored-By: Claude Opus 5 * fix(plugins): serialize sidecar reads and replacements on Windows * Fix API guard false positives for private lambda accessors (cherry picked from commit 9f08f55cc96918c95a72f6f330ec7b2f85e9806a) * test(sandbox): await asynchronous disable notification --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * Do not skip a declared minApiVersion when the API layer is unresolved (#395) * Do not skip a declared minApiVersion when the API layer is unresolved satisfiesMinApiVersion delegated to satisfiesVersionFloor, which answers true for a blank or unparseable installed version. hostApiVersion reads System.getProperty("boss.api.version"), published only once the API plugin resolves, so a check that runs before then skipped the floor entirely and the update was offered and installed. The loader rejects it on the same floor afterwards, which leaves a swapped jar the host cannot load instead of no update: the Toolbox 1.8.4 on BOSS 9.2.25 failure the surrounding comments cite. Concretely, terminal-tab 2.5.74 declares minApiVersion 1.0.88 and implements renameTab, tabActivity and the initialCommand split overloads against it. On a host still carrying API 1.0.87 those symbols are absent. Now fails closed, but only when a floor is actually declared. A blank minApiVersion still passes, because every version published before the column existed carries "" and gating those would withhold every update on a host whose API had not resolved. An unparseable minApiVersion also still fails open, since that value comes from the store and one malformed row should withhold nothing. satisfiesVersionFloor itself is unchanged. Its three other callers want the fail-open answer: the home grid prefers showing a tile, and both retirement checks wrap it in their own fail-closed logic, with tests pinning exactly that. * Address review: fix the guard order and stop withholding updates forever Four things the review on #395 got right. The guard order was a real bug. Asking about the installed version first meant a malformed minApiVersion plus an unresolved API layer withheld the update, while the KDoc claimed a malformed floor always fails open. Parsing `required` first makes the two documented fail-open cases unconditional, and since parse rejects blank it also folds in the no-floor check. Blank was not the transient state the fix assumed. ApiClassLoader.apiVersion is null when no api jar was found, which the host published as "", so a host in that state would have been denied every floor-declaring plugin update for the whole session, every session. hostApiVersion is now () -> String? and the host passes the property through without `?: ""`, so null (not resolved yet) fails closed while "" (resolved, no api jar) keeps the answer it always gave. That also matches DynamicPluginLoader, which skips minApiVersion validation outright when currentApiVersion is null rather than rejecting - so the "loader is the backstop" claim in the old KDoc was wrong for exactly this case. An unreadable installed version now logs its own WARN. It is a property of the host, not of the candidate, and "Skipping plugin update requiring newer API layer" pointed the reader at the plugin. Since a withheld update is otherwise close to invisible, the reason has to be diagnosable. hostApiVersion() was read three times per rejected candidate, and the api layer resolving is precisely the event this branch is about, so the notice could report a version that would have satisfied the floor the check just failed. Read once for the sweep and pass it down. Tests: pin both malformed-floor combinations (the ordering bug), the resolved but absent api jar case, and a prerelease installed version, which satisfiesVersionFloor deliberately ignores and nothing pinned for this gate. The three near-identical fake repositories in this module are now one shared FakeSingleVersionRepository. Both new assertions were confirmed to fail against the previous implementation before being kept. * Add a deterministic tiebreaker to the secret paging RPCs (#385) get_user_secrets, search_user_secrets and get_user_secrets_with_shared all page with ORDER BY s.created_at DESC LIMIT p_limit OFFSET p_offset and nothing else. secrets.created_at is timestamptz DEFAULT now() with no unique constraint, and now() is the transaction clock, so every secret written in one transaction shares it exactly. That makes the sort key a partial order, and Postgres may emit tied rows in any order the plan happens to produce, differing between two calls. LIMIT/OFFSET then slices that unstable sequence. A tied row that sat last on page 1 can land one position earlier on the next call, inside the slice the caller already consumed and before page 2's OFFSET, so a full walk returns it zero times and some other tied row twice. Nothing errors. A plugin MCP tool that walks get_user_secrets_with_shared by offset looking for a secret by id reports "no secret with id X" for a secret that exists. Appending s.id DESC fixes it: id is the primary key, so unique and NOT NULL, and (created_at DESC, id DESC) is a total order. Every row then has exactly one position, the same query returns the same sequence, and page boundaries line up with no gap for a row to fall into. 20260307000002_fix_views_security_invoker.sql applied the same reasoning to plugins_with_latest_version. A new migration rather than an edit to the 20260802* files, which are applied on production. Replacing a function body needs the whole definition, so all three are reproduced verbatim from their current migrations with only the ORDER BY line changed; each body was extracted mechanically and diffed against its source to confirm that. No index. (created_at DESC, id DESC) on public.secrets would let the planner read the order instead of sorting per page, but that is a separate change with its own write-amplification and locking profile, and the order is total without it. * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * feat(remote-ui): a wants_keys capability gate for out-of-process plugin surfaces (#441) * feat(remote-ui): a wants_keys capability gate for out-of-process plugin surfaces A remote surface with an interactive widget (a text field, a button) receives every unclaimed key press once that widget holds focus - KeyEvent forwarding was never conditioned on anything the plugin declared. docs/KEYBOARD_SHORTCUTS.md already names this as a known, unbuilt gap, and the reviewers of #51 (which first wired KeyEvent onto the wire) went further: RemoteWidgetRenderer.kt's own KDoc records that an earlier revision made the surface itself directly focusable, was rejected in review as 'the wrong default to ship', and named the fix 'granting it later behind a declared wants_keys on UIRegistration' as the additive follow-up. That flag was never added - the mitigation that shipped instead (the surface itself is not a focus target) only closes half the gap: any surface with so much as one interactive child widget already gets every unclaimed key while that child has focus, regardless of whether the plugin has any reason to see one. This adds the flag reviewers specified, and closes the real gap rather than the narrower one: UIRegistration.wants_keys (proto field 8, false for every plugin built against an older host - additive, not a breaking change). False is the default and the fail-closed answer throughout: RemoteUiSurfaceDescriptor carries it from registration, RemoteSurfaceComponent re-reads it off the registry on every host callback (registration can race a host attaching first, so there is no single 'read once' moment safe to hardcode), and RemoteWidgetRenderer omits the key-forwarding modifier entirely for a surface that never declared it - not attach it and have it decline everything, but never install a code path that sees a key at all. A plugin gains nothing by omission: unset reads as false in proto3, matching every plugin that predates this field. Tests: RemoteUiSurfaceRegistryTest pins the descriptor round-trip through registration. RemoteSurfaceInputComposeTest adds the negative case beside the existing positive one - same widget tree, same focus, same unbound chord, the only difference being the registration under test - proving no Key event reaches a plugin that left wants_keys unset, through the real registry -> component -> renderer pipeline, not just the routing rule in isolation. * fix(remote-ui): enforce key capability at the receiving queue * fix(remote-ui): deliver capabilities without callback registry reentry * test(remote-ui): pin key capability teardown and clarify contracts --------- Co-authored-by: Shivang * fix(search): honor typed case and avoid separator-list allocations (#453) * fix(search): correct exact-case scoring and eliminate heap allocations in FuzzyMatcher * fix(search): preserve typed case through global search scoring * style(search): use expression bodies after case preservation * fix(search): preserve indices for expanding Unicode lowercase * refactor(search): clarify casing contracts and remove redundant guard * docs(search): state matcher index and cached casing contracts --------- Co-authored-by: Shivang * test(tabs): preserve the all-open tab inventory contract (#438) * fix(tabs): track only visible active tabs in split views * fix(detekt): suppress PackageNaming and fix EmptyFunctionBlock in test * test(tabs): preserve complete inventory for background tab consumers * test(tabs): cover unselected panels and keep assertions readable * style(tests): apply ktlint formatting to inventory assertions * test(tabs): clarify workspace scope and cover cross-workspace duplicates --------- Co-authored-by: Shivang * fix(crash): resolve contained-fault attribution on the faulting thread (#456) * fix(crash): resolve contained-fault attribution on the faulting thread A contained render fault raised while the host was inside a plugin call was written to ~/.boss/crash-reports as "(unattributed)", so the report could not be correlated with the plugin it belonged to. That is the missing half of BossConsole#394: the report reached disk, it just did not say who to blame. CrashHandler.attributePluginId has three ranks - the boundary tag, the thread's plugin scope, then the classloader scan. Only rank 2 is thread- affine: PluginExecutionBoundary.currentPluginId reads a ThreadLocal. recordContained hands report building to the boss-contained-crash-writer thread, where that ThreadLocal is always empty, so rank 2 was structurally dead on this whole path. Rank 1 could not cover for it either: a fault the host caught never escapes a runAttributed frame, so nothing tags it. The ladder fell through to the classloader scan, which finds only host frames when host code faults on a plugin's behalf - exactly the reported case. The writeInline branch runs on the calling thread and so read the scope correctly, which meant one fault attributed differently depending on whether the process was about to exit. recordContained now samples the scope where it is valid and passes it down, alongside the report directory it already resolves on the caller for the same class of reason. Only the scope is captured: the other two ranks key off the throwable and are correct on any thread, and the classloader scan is the expensive step this path deliberately moved off the EDT. attributePluginId takes the sampled scope as a defaulted parameter, so the ladder order is unchanged and every existing caller keeps reading it on its own thread. A captured null is indistinguishable from finding no scope, so a genuine host fault stays unattributed. Three tests: a fault reported inside a scope and never escaping it (so no tag can mask the bug) attributes on the async path; inline and deferred writes agree for one fault; a host fault outside any scope stays "(unattributed)". Touches neither the restart-limit persistence of #436 nor the browser listener guards of #400 and #442. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SPf93EJ8Yj1E6HmVTXCE5D * fix(crash): resolve Detekt code quality violations in attributePluginId * style: convert attributePluginId to expression body to satisfy ktlint * test(crash): pin captured attribution priority and null semantics * docs(crash): clarify contained attribution scope and prune baseline * test(crash): separate captured scope invariants and clarify docs --------- Co-authored-by: Claude Co-authored-by: Shivang * test(ipc): await subscriber readiness before publishing a batch (#433) * fix(ipc): retry publishBatch until the subscriber has received something publishBatch delivers all events used a single publishBatch call after only awaitSubscriberRegistered(), a weaker guard than the file's own documented pattern - the RPC handler running is still ahead of the flow being collected, and MutableSharedFlow has no replay, so a publish landing first is lost for good. Flaky specifically on the Windows CI runner, the slowest leg. Retry only while nothing has arrived, since republishing an already-arriving batch would inflate the count this test asserts exactly. * test(ipc): await a readiness probe before publishing the batch * test(ipc): unify batch size and fail fast on ended subscribers --------- Co-authored-by: Shivang * fix(plugins): exempt bundled plugins from store-signature enforcement (#102) (#435) * fix(plugins): exempt bundled plugins from store-signature enforcement (BossConsole#102) Flipping PluginSignatureEnforcement.enforceUnsigned on would hard-fail loading every bundled plugin - they ship inside the signed, notarized app image (downloaded from GitHub releases at build time, not through the signed store), so none carry a .sig sidecar today. That is the first of two blockers the issue names for the flip; this closes it. Adds PluginBundledTrust: a content-addressed .bundled-trust marker, written only by PluginStoreSetup's bundled-copy step right after copying from the trusted bundled directory, and checked by verifySignatureOrThrow as a permanent (not rollout-scoped) exemption. Binding to sha256 means a marker cannot be forged by plugin content, and does not survive the file at that path being replaced by something else. Does NOT flip the enforceUnsigned default. The issue's second blocker - sidecars backfilled across the installed base via #101 - only covers the curated system_plugins list, not every previously-installed store plugin, and whether that backfill has actually reached the fleet is a production-database fact this environment cannot verify. Flipping blind risks the exact failure mode the issue names: the Toolbox itself becomes unloadable. That remains a one-line flag flip for whoever can confirm backfill coverage. * fix(plugins): bind existing bundled copies to trusted source bytes * style(tests): format bundled trust regressions * fix(plugins): preserve bundled trust through reconciliation and rollback --------- Co-authored-by: Shivang * fix(paths): return no parent for bare filenames (#421) Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(security): encrypt TOTP secrets at rest (#417) * fix(security): encrypt TOTP secrets at rest * fix(security): apply TOTP RPC upgrade and restrict helper grants * test(security): expose real upgrade migrations to pgTAP container * test(security): assert anonymous RPC execution is denied * test(security): document and verify TOTP writer boundaries * test(security): make TOTP review checks portable and explicit --------- Co-authored-by: john k Co-authored-by: Shivang * Fixes #147: Harden Supabase response decoding (#351) * Fixes #147: Harden Supabase response decoding - Add coerceInputValues = true to supabaseJson - Add per-row recovery helper decodeListRecovering - Replace decodeFromJsonElement with decodeListRecovering in SecretService and RoleService - Sanitize RestException in sanitizeSupabaseFailure - Expand strict Json guard repo-wide in SupabaseWiringTest * fix(supabase): satisfy ktlint formatting * Fix recovery diagnostics and preserve paginated secret offsets * Address decoding review failure boundaries and guard coverage * test(supabase): pin recovery and coercion safety boundaries --------- Co-authored-by: Shivang * fix(updater): remember unsupported macOS refusals and clean up downloads (#343) * fix(updater): dismiss unsupported macOS update * chore(updater): document installer size exception * fix(updater): consolidate refusal cycle coverage and restore artifact guards Adapt the check-download-refuse-recheck regression from Antriksh1984 in PR #440 (64b172bd5) to the existing typed outcomes and isolated settings in PR #343. Preserve Ayush04-C original implementation and subsequent revisions in history. Reviewer repairs restore stale-path matching and four lost regression tests, prevent cleanup of a restaged path, run cleanup for refused downgrades and canceled persistence, isolate cycle settings, and close test managers. These repairs are separate from the original contributor work. Co-authored-by: Antriksh1984 * refactor(updater): flatten refusal cleanup branch for detekt * fix(updater): retain newer refusals and serialize artifact ownership Do not let an intermediate release erase a newer suppressed version. Publish install outcomes only while that install still owns state. Serialize downloads, installs and discards across suspension so a refusal cannot delete bytes from a subsequent download; stale install/discard requests do not consume a later staged state. Reviewer regressions cover the intermediate version, retained staged metadata, same-path reuse and a download waiting for refusal cleanup. The banner Retry action remains an explicit forced check; manual unsupported-macOS verification is still pending. * refactor(updater): simplify the preserved-dismissal predicate * style(updater): wrap concurrency regression assertions * style(updater): apply verified formatter output * fix(updater): retain active download and restart ownership --------- Co-authored-by: ayush Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(browser): apply context menu setup to all created browser instances (#445) * fix(browser): apply context menu setup to all created browser instances * fix: CI workflow * fix(browser): cover fallback suppression and keep review scoped * refactor(browser): separate popup window construction from menus * refactor(browser): isolate shared chrome entry point * refactor(browser): share context-menu suppression and response handling * fix(browser): preserve popup rationale and isolate setup failures * test(browser): verify cleanup when initial chrome setup fails * refactor(browser): express failed setup cleanup with finally * fix(browser): apply cleanup consistently and resolve review leftovers * style(browser): brace regression test branches consistently * fix(browser): release managed profile after fatal chrome setup failure --------- Co-authored-by: Shivang * fix(secrets): model organisation ownership fields on secret entries (#356) * fix(secrets): model organisation ownership fields on secret entries get_user_secrets, search_user_secrets, get_user_secrets_with_shared and get_secret_shares were extended with org_id/org_slug/is_org_owned/can_manage (and shared_with_org_id/shared_with_org_slug) by the organisation migration, but SecretEntry/SecretEntryWithSharing/SecretShareEntry never gained fields for them - decoding silently discarded the columns (#146). The concrete bug: an org admin who did not create a secret has is_owner=false and access_level="org", a value the client has never modeled, with nothing to tell a caller they are still allowed to manage it. can_manage is what fixes that once a UI reads it - nullable and fail-closed (null/absent means "cannot manage"), matching the server's own can_manage_secret() semantics. Adds a decoding test for the access_level="org" case directly, plus coverage of every new column across all four RPCs. Also fixes a pre-existing test canary in SupabaseJsonTest that happened to use org_id as its "unknown key" example - now that the key is modeled, it no longer exercises the unknown-key path, so the canary was moved to a still-genuinely-unmodeled column name. Out of scope, and left for follow-up in other repositories: the Secret Manager UI itself (edit/delete affordances gated on can_manage, showing org ownership in the list) lives entirely in the separate secret-manager plugin repo, not here. The plugin-facing SecretEntryWithSharingData/SecretShareData classes come from the externally published, version-pinned boss-plugin-api jar and have no org fields either - those need a new upstream release before a plugin can see any of this. This PR only fixes the host-side model, which is the part that can be done inside BossConsole itself. * Correct organisation sharing semantics and pin unknown permissions * Add canManageOrDeny fail-closed accessor to secret models Suggested in review: the fail-closed reading of the nullable can_manage column (unknown/null means "cannot manage") lived only in a comment, so a caller had to reimplement `canManage == true` at every gate rather than grepping for one name. Added to both SecretEntry and SecretEntryWithSharing. Extends the existing absent/explicit-null test to also pin canManageOrDeny == false in both cases, plus a positive case in the get_user_secrets decoding test. * test(secrets): cover explicit organisation management denial --------- Co-authored-by: Shivang * Fall back to the plugin store when GitHub cannot supply a system plugin (#420) * Fall back to the plugin store when GitHub cannot supply a system plugin system-plugins.json routes the headline surfaces (Terminal Tab, Fluck Browser, Editor Tab) through GitHub Releases, and an unauthenticated host gets 60 API calls an hour shared across everything on its egress IP. A CI runner, an agent VM or an office NAT burns that quickly, and downloadSystemPluginFromGitHub treats the resulting 403, the non-2xx, and a release with no matching JAR asset as terminal. The caller logs "Failed to download system plugin" and moves on, so a signed-in BOSS comes up with no terminal, no browser and no editor, while the same plugins install fine from the store moments later. Those three branches now record the plugin as store-repairable with the concrete reason, and a deferred pass installs it from the store. Deferred is the whole point. PluginStoreConfig.accessToken is still null while ensureSystemPluginsInstalled runs from initialize(), and the store download route 403s an unauthenticated caller for any plugin with non-empty requiredPermissions, which is exactly the set this targets. An inline fallback would fail for the same plugins on every launch and look like the fix was in. The pass therefore hangs off the same auth trigger the sidecar backfill uses, but is not one-shot: GitHub failures can be queued at any point, including from the realtime manifest path long after the first token arrived. The minVersion branch is deliberately NOT marked repairable. It refuses a release the host has declared too old, and reaching around it to the store would install the exact contract-breaking JAR the gate exists to prevent. Repairs go through RemotePluginRepository.downloadPlugin, which enforces the store signature, so a repaired JAR arrives signed even though the GitHub path it replaces carries no sidecar. An existing enabled=false is preserved, because repairing a missing file must not re-enable something the user turned off. Both outcomes report at WARN carrying the GitHub reason that led there. The silent version is what #399 was filed about: the wizard's log said only "Some plugins failed to install", so an operator could not separate a rate limit from a missing asset without reading the host log. Scope: the repaired JAR is not loaded into the running session, so it becomes active on the next launch. Loading it live needs a DynamicPluginManager, and those are per-window while PluginStoreSetup is process-wide, so holding one here would pin a window's manager and its classloaders for the life of the process. That is a leak this repo already guards against, and it is not worth introducing to save a restart. Directions B and C from the issue are out of scope and said so on the issue. Co-Authored-By: Claude Opus 5 * Validate and safely promote deferred store repairs * Fix repair test formatting and keep cleanup in finally * Repair system plugins after GitHub transport failures * refactor(plugins): clarify store repair control flow * fix(plugins): contain store staging creation failures * docs(plugins): retain authentication wake-up rationale --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * refactor(plugins): split two self-contained pieces out of PluginStoreSetup.kt (#447) (#448) * refactor(plugins): split two self-contained pieces out of PluginStoreSetup.kt PluginStoreSetup.kt is a 2,089-line god object owning six unrelated concerns (#447). Rather than attempt the full eight-step refactor in one pass, this extracts the two pieces that are safely, mechanically verifiable without a live app run: - SidecarBackfillCoordinator (step 7): already a standalone class driven entirely by constructor lambdas, with zero references to PluginStoreSetup's own state. Moved to its own file in the same package, so no caller or test needed to change - a pure file move, not a behavior change. - isNewerVersion, isTooOldForHost, pickPluginJarUrl and extractVersionFromJarFileName (step 6): four pure, stateless functions already `internal` for test access. Extracted into a new PluginVersionComparator object; the ~9 internal call sites and the test file (renamed PluginVersionComparatorTest, same assertions) were updated to match. PluginStoreSetup.kt drops from 2,089 to 1,894 lines. The remaining six steps (GitHubReleaseClient, SystemPluginInstaller, PluginSignatureResolver, BundledPluginCopier, readPluginManifest relocation, and the full initialize()/loadPersistedPlugins()/shutdown() orchestrator shape) are not attempted here - several of them touch the plugin-install and startup-restore paths the issue's own acceptance criteria says to verify via `./gradlew run`, which this environment cannot do. * Keep extraction references current and simplify the preserved condition --------- Co-authored-by: Shivang * fix(paths): preserve roots and normalize repeated parent separators (#422) * fix(paths): return no parent for bare filenames * fix(paths): preserve roots and normalize repeated parent separators * test(paths): verify root identity through tab comparison and clarify limits * test(paths): report platform skips and clarify lexical label rules --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(urls): reject out-of-range ports in OS-opened URLs (#425) * fix(urls): reject out-of-range ports in OS-opened URLs * test(urls): clarify numeric ports and cover padded invalid values --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * feat(plugins): resolve and install a missing dependency's whole closure from one dialog (#429) * feat(plugins): resolve the transitive install plan for a missing dependency * feat(plugins): let the store-backed installer plan and install a dependency closure * feat(plugins): show and install the whole dependency closure from one dialog * docs(plugins): record that the dependency dialog now resolves and installs the closure * fix(plugins): preserve dependency consent and detached plan completion * fix(plugins): report detached plan errors and reset prompt names * fix(plugins): propagate display-name lookup cancellation --------- Co-authored-by: Shivang * feat: improve plugin onboarding wizard (#443) * feat: improve plugin onboarding wizard * fix: resolve ktlint violation in wizard tests * fix: resolve ktlint import ordering * fix(onboarding): keep requirement labels visible and clarify install failures * fix(wizard): preserve selected styling for required tools * fix(wizard): keep scaled completion content and card semantics accessible * test(wizard): assert required label bounds in the unmerged tree --------- Co-authored-by: Shivang * fix(plugins): scope OOP process IDs by window (#449) * fix(plugins): scope OOP process IDs by window * fix(build): exclude OOP process identity tests on Windows ARM64 * test(build): keep ARM64 source exclusion mirror in sync * fix(plugins): bound window process IDs and preserve plugin metadata * fix(plugins): preserve process diagnostics with opaque identities * test(plugins): pin performance process reflection contracts --------- Co-authored-by: Shivang * fix(plugins): don't delete a superseded jar a live classloader still has open (#431) * fix(plugins): defer removing a jar a live classloader still has open (#72) Rebuilt against the current reconciler after main absorbed an unrelated scoping refactor (pluginIds parameter, readCandidates/repointInstalledEntry extraction) that conflicted with this branch's stale base. Same fix as before: PluginClassLoader.isPathOpenByLiveLoader(path) plus a ReconcileResult.deferred list, wired through the extracted reconcileLoser so a superseded jar still open by a live classloader is left in place and swept on a later reconcile, instead of breaking pty4j's own-jar reopen. * fix(plugins): publish loader lifecycle state before reconciliation * fix(plugins): enforce close-only unloaded transition * fix(plugins): retain live jar protection through classloader close * docs(plugins): explain classloader close-state invariants * docs(plugins): qualify resource availability during close --------- Co-authored-by: Shivang * fix(plugins): restore imports required by store repair integration * feat(cli): add plugin init, validate, and link commands with dev hot-reload Adds `boss plugin init`, `boss plugin validate`, and `boss plugin link` to the CLI so developers can scaffold, check, and test plugins without manual staging or restarting the desktop app. ### Changes #### Project scaffolding (`boss plugin init `) - Scaffolds a buildable Gradle Kotlin Multiplatform plugin project using one of four templates: `mcp-tool`, `ui-panel`, `background-service`, or `full`. - Resolves the host `pluginApiVersion` dynamically, with fallback to compile-time constants. - Supports `--dir` for custom target locations, `--template` to select structure, and `--json` for automation. #### Manifest and bytecode validation (`boss plugin validate []`) - Inspects plugin source directories or packaged `.jar` files. - Checks manifest syntax, semver formats, required IDs, and declared `minApiVersion`. - Verifies that bytecode entrypoints exist on the classpath and implement expected plugin interfaces. - Checks requested permissions against canonical Supabase permissions (migration added in `supabase/migrations/20260908000000_canonical_plugin_permissions.sql`). #### Staging and live reload (`boss plugin link []`) - Stages the compiled plugin JAR into `$BOSS_HOME/plugins/dev//v/.jar` (requires running `./gradlew build` first when linking from a source directory). - Dispatches `PLUGIN_DEV_RELOAD` across the local single-instance socket to reload the plugin live if BossConsole is running. - Exits 0 and leaves staged files on disk when the host is offline, ready for the next startup. #### Startup deduplication and packaging fixes - `DefaultPlugin.kt` groups dev JARs by plugin ID and selects the latest timestamp directory. Legacy flat JARs in `$BOSS_HOME/plugins/dev/` are discarded when a version-rotated JAR exists, preventing duplicate tool registrations. - Bundles `version.properties` into `composeApp/src/desktopMain/resources/` so dynamic API version lookups succeed when the CLI runs outside the repository root. - Caps exception cause traversal in `SingleInstanceManager.kt` with `.take(15)` to prevent infinite loops from circular causes. - Enforces `StandardCharsets.UTF_8` across all IPC socket readers and writers. - Sets file mode `100755` in the Git index for `scripts/boss` and `scripts/test/test-headless-cli.sh`. Added `.gitattributes` to enforce LF on all shell scripts. ### Verification - `PluginManifestTest`: 11 passed, 0 failed. - `PluginEndToEndEvalTest`, `PluginValidatorEvalTest`, `PluginScaffolderEvalTest`: 19 passed, 0 failed. - `scripts/test/test-headless-cli.sh`: 13 test suites passed. - `./gradlew detekt ktlintCheck --no-daemon`: passed with 0 violations. - `./gradlew testClasses --no-daemon`: passed across all 52 tasks. - `git diff --check`: passed with no whitespace or line-ending warnings. * fix(plugins): route host URLs within the requesting instance * fix(cli): reject development reload when host handler is absent * fix(logging): stop a sensitive URL param's colon from leaking a token fragment (#471) * fix(logging): stop a sensitive URL param's colon from leaking a token fragment filePathPattern's [^\s:]+ stops at the first colon (correct for a host:port boundary elsewhere in this file), so a sensitive query or fragment value that happens to contain one - token=abc:def - was only partly consumed. The tail survived sanitizeExceptionMessage verbatim: [url=https://.../cb?token=abc:def] became [url=https:[PATH]:def], leaking a fragment of the token. Measured and reported against #109. Adds a narrow pre-pass that redacts a sensitive-named query/fragment param (by the same nameMarksSecret check the existing name=value pass already uses) before the location passes run, so nothing with a colon in it reaches filePathPattern's boundary in the first place. A non-sensitive param's colon is untouched - out of this fix's scope, and pinned as a regression guard. * fix(logging): consume punctuation within sensitive URL values * fix(logging): reuse URL credential names in the redaction pre-pass * style(logging): wrap URL sensitivity predicate --------- Co-authored-by: Shivang * fix(browser): stop the secret-suggestion scorer over-matching unrelated sites (#460) (#461) * fix(browser): stop the secret-suggestion scorer over-matching unrelated sites WebsiteMatchingUtil.calculateMatchScore decides which saved secrets the Fluck browser suggests for the current page. Three bugs let it offer the wrong site's credentials (#460): - An unanchored `secretNorm.contains(domainNorm)` check scored 0.7 ("domain") for pure substring hits with no word boundary, so a secret for apple.com matched the unrelated site snapple.com. Removed - the exact and subdomain-boundary checks above it already cover every legitimate case a substring test was trying to catch. - The token-overlap "partial" fallback split domains on ".", "-", "_" and treated any shared label as a match, including bare TLDs - so every ".com" secret matched every ".com" site via the shared label "com". Now filters known TLD/public-suffix-ish labels out of both sides before intersecting. - getDisplayName's `replaceFirstChar { c -> ... else it }` returned the whole outer word instead of the char `c` for a non-lowercase first character, duplicating the word for any digit-initial domain name (1password.com -> "1passwordpassword", 9gag.com -> "9gaggag"). Fixed to return `c.toString()`. Scope: this closes the three findings and matches the exact test cases named in the issue (snapple.com must not match apple.com, a .com secret must not match an unrelated .com site, 1password.com's display name). Left alone on purpose: the token-based partial match still lets a genuinely shared non-TLD word match at a low 0.5 score (e.g. "google-workspace.com" still matches "google.com", by original design), which also means a case like login-apple.com still weakly matches apple.com via the shared word "apple" - the issue's own required test list doesn't ask for that case to close, and doing so would mean rethinking the partial-match tier's purpose rather than fixing the three named bugs. WebsiteMatchingUtil had no test coverage at all before this change. * fix(browser): drop the token-overlap partial-match tier entirely Review feedback from the #460 reporter: excluding bare TLD labels from the "partial" fallback's shared-token check closed the "every .com secret matches every .com site" case, but the fallback still matched two different registrable domains that merely share a brand-ish label (apple.com vs apple.org, google.com vs google-workspace.com) - the same wrong-site-credential problem the original fix targeted, just narrower. Adopts the suggested fix: calculateMatchScore now recognizes only exact registrable-domain equality and a real subdomain boundary (one side is a dotted suffix of the other), matching what a password manager keys on. The token-overlap tier and its COMMON_TLD_LABELS allowlist are removed rather than patched further - a credentials surface should favor precision over recall, and no shared-word heuristic closes that gap for good. Also added the empty-string guard from the suggested diff, so two blank sides (an unresolved domain, a secret with no recorded website) can't vacuously exact-match each other. Test file extended per the suggested test list: apple.com vs apple.org, login-apple.com vs apple.com (previously the one case the TLD-only fix left open), a shared-TLD pair, www normalization on both a positive and a negative case, and the end-to-end matchSecretsForDomain offer path - alongside the existing exact/subdomain/display-name coverage. * test(secrets): retain domain regression coverage from PR 467 Retain Sahvendra7 tests alongside Antriksh1984 coverage, and clarify the existing extractor limitation. Consolidation and documentation edits by review agent. Co-authored-by: Sahvendra * docs(secrets): align integration guide with strict matcher --------- Co-authored-by: Shivang Co-authored-by: Sahvendra * fix(editor): resolve run-main language from the file path, not the stored name (#458) (#459) * fix(editor): resolve run-main language from the file path, not the stored name (#458) executeMainFunction resolved the language with Language.fromExtension(mainFunction.language), but MainFunctionInfo.language is serialised as Language.name.lowercase() - a name like kotlin, not a kt extension. fromExtension matches extensions, so every language whose enum name is not also an extension resolved to UNKNOWN, and generateCommand maps UNKNOWN to the literal command echo 'Unknown language'. Only java and go worked, by coincidence. Resolve from mainFunction.filePath instead (via a testable runLanguageForMainFunction helper), matching how detectMainFunctions already derives it. Covered by RunLanguageForMainFunctionTest. * test(editor): cover alternate run-main extensions and path precedence * style(editor): wrap run language helper for detekt * docs(editor): fix KDoc link without changing helper signature --------- Co-authored-by: Shivang * fix(deep-link): report whether a plugin action link actually ran, not a blind OK (#470) * fix(deep-link): report whether a plugin action link actually ran, not a blind OK boss://plugin?id=&action= dispatched to DeepLinkActionRegistryImpl fire-and-forget: the boolean verdict (handler found? action accepted?) was discarded, so SingleInstanceManager always answered a forwarded action link with RESPONSE_OK - even when no handler was registered for the given id, or the handler declined the action. A caller scripting BOSS via boss:// links (CLI, another instance, an agent) had no way to tell a real success from a silent no-op. processDeepLink now returns a Deferred? - null for every route except a plugin action link, preserving today's fire-and-forget behaviour everywhere else. SingleInstanceManager awaits it (bounded, well under the socket's own close budget) and reports a real ERROR instead of OK when the action was never actually handled. * fix(deep-link): bound action dispatch and prevent automatic replay * refactor(deep-link): isolate action response formatting for quality gates --------- Co-authored-by: Shivang * fix(deep-link): stop logging Linux's expected APP_OPEN_URI gap as an error (#437) setupDefaultHandler() (Linux and other non-mac/Windows platforms) caught Desktop.setOpenURIHandler's UnsupportedOperationException with the generic Exception branch, logging it at ERROR with a full stack trace on every Linux launch. This is a documented, expected outcome - the JDK's X11 Desktop peer supports no APP_OPEN_URI action - and delivery already works via processCommandLineArgs reading argv, both at cold start and forwarded to a running instance over the single-instance channel. Mirrors the catch order setupOpenFileHandler already uses for the equivalent APP_OPEN_FILE gap on the same platforms: UnsupportedOperationException first, logged at DEBUG as non-error, before the generic Exception catch. * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Address project announcement review feedback * fix(cli): integrate plugin launchpad with host loader and runtime - Target canonical ai.rever.boss.plugin.api.Plugin and META-INF/boss-plugin/plugin.json in scaffolder - Wire DevPluginReloader on Dispatchers.Main and handle offline host cleanly - Stage plugin jars atomically via .part and preserve active versions on failure - Validate bytecode with ASM ClassReader without running static initializers - Bundle gradle-wrapper.jar for standalone init outside the repository - Read PLUGIN_API_VERSION directly from gradle/libs.versions.toml and drop redundant version.properties - Remove premature RBAC permissions SQL migration and test * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and nam… * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog (#469) * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog McpPolicyEngine.setToolPolicy already writes a durable ALLOW/ASK/DENY rule to ~/.boss/mcp-tool-policy.json and takes effect immediately - it was fully built and tested, but the only caller was this repo's own test suite. AGENTS.md's own governance section names the gap plainly: "Persistent rules currently require editing ~/.boss/mcp-tool-policy.json and restarting." The interactive approval dialog for ASK-governed tools offered only "Approve Once" and "Always this Session" (in-memory, cleared on restart) - there was no way to tell BOSS "never ask about this tool again" without hand-editing JSON. - McpApprovalDecision.Approved/Denied gain a persistPolicy flag, threaded through McpApprovalBus.approve/deny (both additive, defaulting to false - every existing call site is unaffected). - McpToolRegistryCore.authorizeInvocation calls policyEngine.setToolPolicy(toolName, ALLOW/DENY) when persistPolicy is set, recording two new McpApprovalDisposition values (PERSISTENTLY_ALLOWED / PERSISTENTLY_DENIED) in the audit ledger so it's visible which of the three scopes - once, session, persistent - an operator actually chose. A persisted ALLOW makes the existing session-trust call redundant (policyFor checks the configured rule first), so the registry does not also call trustForSession when this is set - one less write, same result. - McpApprovalDialog gains "Always Allow" / "Always Deny" actions, visually secondary to the three existing per-call actions since they are the more consequential, less common choice. Scope: this is the interactive path only. The policy file itself, its fail-closed-on-unreadable behavior, and the kill-switch/RBAC layers upstream of policy resolution are untouched. 8 tests in McpGovernedInvocationTest (2 new) cover persisted ALLOW and DENY end to end: the approval/denial itself, the ledger disposition, and that a second call to the same tool no longer suspends for approval - confirming the rule actually took effect, not just that it was written. ktlintCheck, detekt and the full mcp/app/dialogs test packages all green. * fix(mcp): verify durable approval writes and preserve revocations * feat(mcp): let an operator inspect and revoke a persisted tool policy The review's remaining finding: after "Always Allow" persists a rule, there was no way back except hand-editing ~/.boss/mcp-tool-policy.json and restarting - the new caption and AGENTS.md text explained that, but disclosure is not a reversal control, and "Revoke MCP session trust" only ever cleared in-memory grants, never a persisted rule. McpPolicyEngine.revokePersistedPolicy(toolName) is the operator-facing undo for setToolPolicy's persistent scope: resets the tool to ASK and clears session trust for it in the same call, since policyFor checks session trust before a non-DENY configured rule - revoking only the persisted half would leave a tool that happened to hold both still silently auto-allowed. Returns whether the write actually succeeded, so a failed revoke is reported rather than the UI showing a change that never reached disk. McpPolicyManagerDialog lists every persisted rule (tool name, ALLOW/ DENY, a per-row "Reset to Ask") and shows an inline error for a row whose reset failed to save. Wired into the bottom bar next to the existing session-trust revoke, behind a button that only appears once a rule exists - nothing new to look at when there is nothing to revoke. Not exposed as an MCP tool: this is a human-only control reachable by clicking, never callable by an agent on its own behalf. New tests cover the exact validation the review asked for: a persisted ALLOW revoked from the API resets to ASK and survives a reload from disk; revoking also clears session trust so the next call asks again even when both grants were held; an unrelated tool's DENY rule is untouched; and a failed write is reported through the same fault channel setToolPolicy already uses, not silently swallowed. * fix(mcp): make policy revocation actually remove the rule, off the UI thread revokePersistedPolicy previously rewrote a revoked rule to ASK instead of removing it, so the bottom-bar count never dropped and the policy manager dialog kept listing "revoked" tools with a no-op Reset button; under a hardened defaultMutatingAction=DENY config the leftover ASK was also weaker than the operator's own default. Removing the key instead fixes all three. The revoke write also ran synchronously on the Compose UI thread; onRevoke is now a suspend callback launched from the dialog's own coroutine scope, with the actual engine call wrapped in withContext(Dispatchers.IO) at the call site. A DENY row's reset now needs a second tap to confirm, since it is the one control in this dialog that raises standing privilege rather than lowering it. Stale operator-facing copy pointing at hand-editing the policy file was updated to point at the in-app dialog instead, in both the approval dialog and AGENTS.md's governance section. Left as a documented limitation rather than fixed: a queued approval answered "Always Allow" after the same tool's rule was freshly revoked can still re-persist it, since the existing preserveDeny guard only protects against a newer DENY, not a newer revocation. Closing that needs a revocation generation/timestamp compared inside the policy engine's lock. * fix(mcp): invalidate queued approvals when a saved policy is reset --------- Co-authored-by: Shivang * feat(plugins): add health and recovery center (#454) * feat(plugins): add health and recovery center * fix(plugins): retain recovery feedback and use lifecycle delegate * style(plugins): satisfy health recovery quality limits * fix(plugins): report unavailable recovery and observe sandbox disables * style(plugins): keep health dialog within function limit * style(plugins): remove unused sandbox state helper * style(plugins): format sandbox state lookup chain * fix(plugins): route watchdog recovery through safe reload * fix(plugins): finish sandbox teardown before recovery reload * fix(plugins): complete recovery teardown after caller cancellation * fix(plugins): restore panels when pre-lock uninstall is cancelled * fix(plugins): propagate cancellation before destructive uninstall * fix: prevent disabled plugin sandbox restart * fix: satisfy sandbox recovery static checks * style: format sandbox restart recovery * fix: close sandbox restart admission races * test(plugins): pin disable races and clarify cancelled uninstall outcome * test(plugins): configure budget race fixture explicitly * style(plugins): format sandbox race regression fixture --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): #429 follow-ups: one offer filter, root-named failures, matching cancellation contracts (#483) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails t… * feat(plugins): route a missing-dependency prompt to the window that raised it (#465) * feat(plugins): route a missing-dependency prompt to the window that raised it MissingDependencyPrompt is delivered over a Channel: exactly one window's collector receives each prompt, but not necessarily the one whose install raised it. The class's own KDoc named this as a known, deliberately unbuilt limitation - "routing back to the reporting window would need the prompt to carry a window id and the collector to be able to decline one without consuming it... not built, because a single window is the overwhelmingly common case." This builds exactly that, for the case where it does happen: a person answers "Install" in the window they're looking at and nothing changes there, because a different, idle window happened to collect it. - MissingDependencyPrompt gains windowId: String? = null, resolved via WindowFocusManager.resolveActionableWindowId() at the two report call sites where a window is unambiguously the actionable one right now (MissingDependencyReporter.report, a user-initiated install-completion path; MissingPluginOffer.offerIfMissing, a direct button click) - the same best-effort "whichever window is actionable" idiom this codebase already uses for deep links and CLI commands, not a new standard. - shouldClaimMissingDependencyPrompt (PluginDependencyResolution.kt) is the pure decision a wrongly-chosen collector uses: claim it if there's no reporting window to prefer, if this window IS the reporting window, or if the reporting window has simply closed (a prompt must never wait forever for a window that's gone). Kept pure and tested the same way shouldShowMissingDependency already is. - The collector in BossAppEventBusEffects.kt calls this before showing a prompt; when it says no, the window re-offers the prompt to the bus (a short delay first, so two non-target windows can't trade it back and forth in a tight loop while the real target's own collector is still starting up) instead of displaying it. - WindowFocusManager.isWindowOpen and resolveActionableWindowId are promoted onto the expect/actual contract so commonMain code (where MissingDependencyPrompt and its bus already live) can ask both questions without a desktop-only reference. Scope: PerformanceState.performancePanelPrompt (the status-bar indicator's offer) is left at the windowId = null default - it resolves its plugin manager via DynamicPluginManager.anyActiveManager() with no direct window context available, and threading one through would mean unrelated refactoring of that call chain rather than this feature. Falls back to today's behavior for that one path only, same as every prompt did before this change. 5 new tests (MissingDependencyPromptRoutingTest) cover the decision function directly: no reporting window, own window, wrong window with the target still open, wrong window after the target has closed, and the single-window case. Full desktopTest suite, ktlintCheck and detekt all clean (one pre-existing, unrelated Windows-symlink-privilege test failure in DesktopFileScannerTest, confirmed present on unmodified main). * fix(plugins): return routed prompts before collector suspension * fix(plugins): replace the bounded channel with a lossless prompt registry Review of this PR (batch-4) proved two real bugs in the bounded-channel delivery PluginDependencyBus used: reporting one prompt, receiving it, admitting four unrelated ones, then re-reporting the first refused it - the channel's four-slot capacity had nowhere left to put it, and it was gone. Separately, freeing the dedup guard the instant a prompt was handed to any collector (rather than when a collector was actually done with it) let a second, independent report for the same key be admitted as a competing entry while the first was still in transit. Replaces the Channel(capacity=4) + separate dedup set with one LinkedHashMap, guarded by a single lock so admission (report) and claiming (claim) are each one atomic step. A map has no capacity to overflow, and a key stays reserved from report() until claim() actually removes it - there is no gap for a concurrent report to slip into. missingDependencies now broadcasts every pending prompt to every open window; each window decides independently (via the existing shouldClaimMissingDependencyPrompt) whether to claim(), and a losing racer simply does nothing further. This also removes the reoffer mechanism entirely: a window that is not the right audience for a prompt now just leaves it alone rather than report()-ing it back with a delay, which was the "unbounded routing hops" the review flagged (one wait-and-retry loop per rejected prompt per window). A single bus-owned periodic re-broadcast covers the one case no report() fires for: the preferred window closing with nothing else to wake collectors. New/rewritten tests reproduce the review's own adversarial scenario directly (a prompt survives being re-reported while four distinct prompts are also pending) and prove claim() is atomic under concurrent callers, alongside the existing dependency-resolution and pure-function routing tests, all updated for the claim-based consumption model. * test(plugins): fix a test broken by the claim-based bus consumption model PerformancePanelPromptTest's "forcing twice does not queue two dialogs" assumed the old design's receive-consumes semantics: a plain missingDependencies.first() no longer removes anything from the bus, only claim() does, so the test's second first() call was seeing the same still-pending prompt again instead of timing out. Missed in the original PluginDependencyBus rewrite because this test lives outside the components.plugin package the rest of the affected suite is in - caught by CI (ubuntu/macos/windows build-test all failed on it), not by the targeted test runs during that change. * fix(plugins): re-resolve a disposed manager's installer, fix a claim-timing hole Addresses the three findings from the claim-based-queue review round: - The closed-target fallback (shouldClaimMissingDependencyPrompt's !targetWindowOpen clause) lets a different window claim a prompt once its preferred window has closed - but the prompt's installer was still bound to that closed window's DynamicPluginManager, which disposeWindow() has already cancelled and uninstalled everything from. Install then loaded into a dead manager and reported success having put the plugin nowhere anything renders - the exact #188 symptom this repo's own release notes describe. installerFor now re-resolves to DynamicPluginManager.anyActiveManager() at use time for both the isInstalled check and the actual install, falling back to the originally-bound manager only when nothing at all is live - the same fix anyActiveManager's own KDoc already documents for the home screen's install tile, applied here too. - claim() ran before the isInstalled check's IO stat, so cancelling that coroutine mid-stat (window closing, the effect restarting) permanently lost an already-claimed prompt: it was off the bus with nowhere else recorded. Moved the claim to immediately before the state assignment, after present/show are both decided - a non-suspending sequence from there, so the only remaining window is the documented, accepted one where the dialog itself is already up. The cost is a duplicated isInstalled stat in the rare case two windows reach the check on one prompt at once, which claim() returning false for the loser already handles correctly. - The bus's wake signal (changed, a MutableSharedFlow(replay = 1)) had no extra buffer, so tryEmit returned false - discarded, since report() never checked it - whenever a collector hadn't yet consumed the replayed value, i.e. whenever any window was mid-dialog. That is the common case for a second missing dependency, not a rare one, so the periodic ticker (documented as covering only a closed target window) was doing the real signalling work for the routine case too. Nothing was ever lost - pending still held every prompt - but a second dependency could sit for up to a second before its rescan. extraBufferCapacity = 1 with DROP_OLDEST makes tryEmit always succeed; conflating repeated "go look" signals is exactly right for a rescan trigger. Also: renamed a test that implied claim() was proven atomic under real concurrency - runTest serialises both async bodies, so it never was, and the assertion it actually makes (a second claim of the same prompt fails) is what the new name says. Added a test for the ticker's periodic re-broadcast of a still-pending, unclaimed prompt, which had no coverage at all. MISSING_DEPENDENCY_RESCAN_INTERVAL_MS is internal now (from private) so that test - and any future one - can pin the real interval instead of a copy of it, matching this repo's own precedent for timing constants. Five doc sites (AGENTS.md and four KDoc cross-references) that still described the deleted channel/reoffer design are corrected to describe the current claim-based map registry instead. * fix(plugins): retire suppressed prompts and preserve live installer ownership --------- Co-authored-by: Shivang * fix(secrets): preserve hostnames in the host matching utility (#482) * fix(browser): catch native disposal exceptions to prevent incorrect sandbox disables Wraps native JxBrowser event listeners to prevent ObjectClosedException from escaping into the sandbox manager. Previously, unexpected browser disposal could be interpreted as a sandbox failure and consume the plugin restart budget. * fix(security): tighten browser secret domain matching * Wrap matcher regression tests to satisfy Detekt * Keep full hostnames visible in saved-secret display labels --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shivang * feat(settings): explain experimental Microkernel Mode before enabling it (#481) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names e… * fix(shortcuts): trigger key bindings on key release instead of key press (#492) * fix(shortcuts): trigger key bindings on key release instead of key press * fix(shortcuts): invoke a bound action on key-up, not key-down (#490) A shortcut's action ran from the KEY_PRESSED event, so holding a chord past the OS auto-repeat threshold fired it repeatedly (Cmd+N held down kept creating tabs), and the action always ran while the keys were still physically held. AWTKeyboardInterceptor.install() now recognizes a matching chord on KEY_PRESSED - claiming it (so it still doesn't leak to BossTerm/a browser while held) - but only arms it as a PendingShortcut. The bound action fires exactly once, from handleKeyReleased, when the primary key's own KEY_RELEASED arrives; a repeat KEY_PRESSED for the same key stays claimed without re-arming or re-firing. Releasing a modifier by itself can never match, since a modifier key is never allowed to arm in the first place. Focus moving to another window (or away entirely) clears any armed-but-unfired chord via a focusedWindow property listener, and uninstall() clears it too. dispatchAction gained a `perform` flag (default true, so every existing call site is unchanged) so handleKeyPressed can ask "would this dispatch" - reusing the exact same gate each action already had (canStepTabs, activePanelTabCount, panelCountState, ClosedTabHistory) - without performing the trigger, and handleKeyReleased later asks for real. This is one function answering both questions rather than two that could drift, at the cost of a narrow, documented race if the gate's answer changes in the few hundred ms between press and release. PluginShortcutRegistryImpl.dispatch has no such probe, so a matched plugin default arms optimistically and is confirmed for real at release. KeyboardShortcutInterceptor.kt (a second matcher against the same KeymapMatcher, not wired to any production caller today) gets the same arm-on-KeyDown/emit-on-matching-KeyUp treatment for consistency. New tests drive handleKeyPressed/handleKeyReleased directly with synthetic AWT KeyEvents (the same way the existing gate tests drive dispatchAction/dispatchIfCanStepTabs directly), covering: a key-up fires exactly once, a mismatched or modifier release does nothing, a repeat key-down neither re-arms nor re-fires, a gate closing between press and release is honored at release, and cancelling drops an armed chord. * fix: wokflows * fix(shortcuts): validate pending chord lifetime and release ownership * fix(shortcuts): fix MRU wedge, stale-arm swallow, and dropped-chord bugs (#490) Addresses the three substantive findings from review: - MRU tab cycling wedged open when the modifier released before the primary key. Arming (tabCycleActive/tabCycleModifierKeyCode) had moved to handleKeyReleased, where the cycling modifier is no longer guaranteed still down - the OS can deliver the two key-ups in either order, and releasing the modifier first meant arming for a release that had already happened. Extracted armTabCycleIfApplicable and call it from handleKeyPressed instead, where the modifier is guaranteed held (it's what made the chord match). - A lost KEY_RELEASED (swallowed by a native popup, a heavyweight surface, or an OS-level chord grab, with focus never moving so nothing else cleared it) left a stale armed entry that the repeat- claim check matched on keycode alone - so a later, unrelated bare press of the same physical key was silently swallowed, and its key-up fired the shortcut for a keystroke that was never meant to be one. PendingShortcut now records the arming event's four modifier flags, and the repeat-claim check requires them to still match; a mismatch falls through to normal re-matching instead, which also makes a stale entry self-heal on the next genuine press of that chord rather than staying stuck until focus changes. - Rolling one chord into another without fully releasing the first (Cmd+N into Cmd+T at speed) evicted it from the single pending slot, so its eventual release found nothing to fire and leaked to the focused component with no matching key-down to explain it. pendingShortcut is now pendingShortcuts, keyed by physical keycode, so concurrently-held chords on different keys get independent slots. Also: a release that matched and cleared an armed chord is now always consumed, regardless of whether the underlying dispatch reports handled - the key-down was already withheld from the focused component, so letting the key-up through alone on a refused gate or a plugin that unregistered mid-chord left a listener a key-up with no matching key-down to make sense of. tabCycleActive/tabCycleModifierKeyCode are now internal (from private) so a test can pin the arm-at-press behavior directly. KeyboardShortcutInterceptor.kt (still unwired to any production caller) gets the same stale-arm protection via onFocusChanged, for the "consistent key-up semantics" criterion the next person wiring it up will actually see. New/updated tests: armTabCycleIfApplicable's MRU-gating in isolation (arms for TAB_NEXT/TAB_PREVIOUS under MRU, does nothing otherwise or for an unrelated action), a structural proof that handleKeyReleased no longer arms the cycle itself, a stale-arm-with-different-modifiers regression, a two-independent-chords regression, and the gate-race test updated to assert consumption instead of the old handled-based return. 16/16 ShortcutKeyUpInvocationTest, all touched suites green. * fix(shortcuts): finish reviewed cancellation and headless regressions * refactor(shortcuts): resolve CI complexity findings * test(crash): tolerate transient Windows report read locks * test(crash): wait for drain marker deletion on Windows --------- Co-authored-by: Antriksh Co-authored-by: Shivang * review: drop repo-wide .gitattributes from the plugin CLI change The EOL policy change applies to every file and every contributor's checkout, not to the launchpad feature. It belongs in its own maintainer-decided change; keep this PR scoped to the plugin CLI. * fix(launchpad): only treat LOADED installs as confirmed reloads installPlugin returns Result.success with state DISABLED when registration fails as binary-incompatible or the plugin is hidden for lack of access, so an isSuccess check reported a confirmed reload for a plugin the host then force-unloaded. Check state == LOADED and log the real state. Also drop waitForGC=true: the 30-second System.gc() poll loop ran on Dispatchers.Main and could freeze the UI. Every other host reload path takes the false default and lets the old loader be reclaimed out of band. * fix(plugin-cli): truthful reload outcomes, validated ids, launcher exit codes - reloadDevPlugin: 45s client budget, 60s server budget for the verb, and a TimedOut result (host alive, reload unconfirmed) distinct from HostOffline, decided by a post-timeout probe ping. The CLI prints a staged_unconfirmed state instead of exit 0 for every outcome. - VERB_PLUGIN_DEV_RELOAD validates the plugin id on the wire (no separators, not ".."; otherwise REJECTED); the client pre-validates. - boss.bat: the ps1 branch swallowed exit codes because !ERRORLEVEL! stays literal under DisableDelayedExpansion and %ERRORLEVEL% parses stale inside a parenthesized block; the branch is now a label parsed after powershell returns. - PluginValidator closes its URLClassLoader (the jar handle stayed open per validation, notably on Windows). - Drop unreachable HostMeta.DEFAULT_API_VERSION; the manifest default uses the generated constant. Tests: wire rejection of traversal ids, TimedOut vs offline split, client-side id refusal. * fix(cli): enforce reverse-domain plugin ID, host manifest validation, and safe staging retention * fix(db): stop materializing inherited client routine access * fix(logging): mask a URL whose fragment comes before its query (#541) Preserve masked URLs when a fragment precedes the query. Original implementation and four regression tests by @arjun28115. Reviewed current-head Claude feedback and passing CI; existing adjacent nested-query behavior remains a documented follow-up. * test(config): isolate Chromium flags test from local environment variables (#507) * test(config): isolate Chromium flags test from local environment variables (cherry picked from commit c6bce0ded1a4157b84cb724577f89ff9a0cd3cc9) * test(config): pin the env-wins and blank-UNSET rules the envReader seam unlocks Addresses the Claude review of #507: - move the envReader seam declaration up beside settingsFile so the KDoc reattaches to envOverride and the seam is initialised before bootSettings = loadSync() (object initialiser declaration order); - previewValue test now exercises the environment outranking the setting and the blank-reads-as-UNSET guard, both untestable before the seam existed; - reword two comments that the seam made stale. * test(config): close out the second Claude review of #507 Addresses the re-review of 6bcd83c73 (comment text only, plus one hermeticity line in a test): - the init-order comment read inverted - it called an initialiser below the envReader declaration the hazard, when the hazard is an initialiser ABOVE it (which is exactly what the old layout had, with bootSettings declared above the seam); reworded; - 'envOverride reads the environment only' now stubs envReader to null for its own assertions, so it is hermetic by construction rather than by the BOSS_TEST_UNUSED_KEY naming convention. --------- Co-authored-by: Shivang * fix(search): route cross-window tab selections to target window (#500) * fix(search): route cross-window tab selections to target window * refactor(search): focus target window via WindowFocusManager, wire TabEventBus to kernel IPC * fix(search): honor closed target window, forward origin window in TabEventBus Addresses the Claude review of the cross-window tab selection change: - TabEventBus.selectTab now takes sourceWindowId (the dialog's own window) and forwards it as the IpcEventBridge envelope source, matching the origin contract the other twelve buses and EventBusServiceImpl rely on. - TabSelectEvent is @Serializable so kernel-mode forwarding emits structured JSON instead of the toString() fallback with a WARN. - onTabSelect honors WindowFocusManager.focusWindow's Boolean result: a closed target window now logs a warning instead of emitting into the void (the bus has no replay). - The 50 ms effect delay is named TAB_SELECT_FOCUS_GRACE_MS with a comment stating it is UX grace, not a correctness barrier. - TabSelectEventTest: the no-replay test proves live delivery first and only then asserts the late collector sees nothing; both tests use explicit subscription barriers instead of yield(); ipcBridge is reset in @AfterTest; KDoc states the tests pin the bus contract, not the shipped BossAppEventBusEffects filter. * docs(search): correct bus KDocs after destination-addressed TabEventBus Per Claude delta review at af7c292: - BossAppEventBusEffects file KDoc: note the TabEventBus exception to the window-filtered-by-sourceWindowId rule (Issues #498/#506). - TabEventBus KDoc: spell out the cross-process subscription consequence (subscribers must not set SubscribeRequest.sourceWindowId to their own window id; route on the payload's targetWindowId) and fix the 'this file's twelve sibling buses' wording. - TabSelectEventTest KDoc: the bus carries two properties, not three. --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux (#535) * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux - Problem: MenuShortcutBridge.getKeyShortcut() always set meta=true for Cmd bindings, which Compose Desktop maps to the Super/Windows key on Windows/Linux, so native menu items like Ctrl+N/Ctrl+, failed to fire. - Fix: resolve effectiveCtrl and effectiveMeta from SystemUtils.isMacOS before constructing KeyShortcut, matching the platform-aware mapping in KeymapMatcher and AWTKeyboardInterceptor. - Verification: ./gradlew :composeApp:desktopTest --tests "ai.rever.boss.keymap.ShortcutCaptureConsumersTest"; :composeApp:ktlintCheck; :composeApp:detekt. * fix(keymap): gate browser zoom/reload menu items, make mapping testable per-platform Addresses the Claude review of the menu accelerator change: - The View menu's Actual Size / Zoom In / Zoom Out / Reload items now carry enabled = hasBrowser like Back/Forward/Developer Tools. Their bindings are BROWSER-context and AWTKeyboardInterceptor declines them outside a browser tab, so before this the newly-live Ctrl accelerators on Windows/Linux could fire them from a terminal or editor tab. - The platform modifier mapping is extracted into the pure menuAcceleratorModifiers(hasCmd, hasCtrl, isMacOS), with the asymmetry comment moved with it. The test now pins both platform branches unconditionally on any runner; the bridge-level tests keep asserting the live SystemUtils wiring. - One-line comment keeping the legacy packed-keycode coverage attached to Key.N.keyCode.toString(). * docs(keymap): record the deliberate menu-gate asymmetry in three stale comments Per Claude delta review at 928d3b206: after gating the four View items with enabled = hasBrowser, three comments still described the pre-gate behaviour. - windowsWithActiveBrowser KDoc now enumerates all seven gated items. - activeBrowserWindows KDoc records that the gate is deliberately stricter than the dispatch target (sidebar-slot / background-split browsers lose Zoom and Reload from the View menu; the broader gate would swallow Ctrl+R and Cmd+[/Cmd+]). - The 'gate is stricter' test restates the asymmetry without the now-false claim that a sidebar browser still gets Zoom and Reload. --------- Co-authored-by: kshivang Co-authored-by: Shivang * fix: keep focus mode rail actions stable (#519) Keep focus-mode quick actions stationary in the collapsed rail while the drawer reveals beside it. Preserve rail interaction when the chevron dismissal catcher is present and keep heavyweight tooltips visible. Original implementation: Ayush Santosh Chhatre (@Ayush04-C). Consolidation source #534: Rohan Gaikwad (@rohangaikwad4), credited for the parallel stationary-rail implementation and permanent-activity-bar framing. Prior review-agent catcher and regression-test repairs remain separately attributed in the PR description and review record. Reviewed head 9e939539c8f971cd45e33aa336d261eb21d5a492: all nine CI checks pass, including three platform builds/tests and code quality. Claude's final import/KDoc findings were addressed; dev does not alter the PR's touched files. Manual visual checks remain recorded in the PR/review inventory. * fix(passkey): escape mobile WebAuthn HTML templates against reflected XSS (#546) Escape mobile WebAuthn template values in their HTML and inline JavaScript contexts to prevent reflected script injection. Original vulnerability analysis, contextual escapers and initial regression tests: Antriksh (@Antriksh1984). Prior agent follow-ups repair replacement-pattern expansion and add single-pass substitution plus additional regression coverage. Agent repairs are separate from the original submission score. Validation: all nine current-head CI checks pass; prior combined current-dev integration passed 167 Deno tests and 784 pgTAP assertions. Claude findings have been addressed with dispositions documented on PR #546. No production deployment is included. Co-authored-by: Antriksh * fix(project): stop the per-window recent-projects provider from leaking, without freezing KERNEL clients (#524) Stop window-owned recent-project collectors at disposal while keeping KERNEL watch streams connected to process-wide state. Original implementation by Antriksh; prior review integration and regression hardening are documented in the PR. Co-authored-by: Antriksh * Stop FileIcons keeping a second copy of the icon extension list (#525) * Stop FileIcons keeping a second copy of the icon extension list `FileIcons.forFile` gated `LanguageIcons.forExtension` behind a `when` whose 52 branches were byte-identical: each listed some extensions and then called `LanguageIcons.forExtension(extension).toFileIconInfo()`. The branches carried no behaviour, only a vocabulary, and that vocabulary is already declared in LanguageIcons' own `when` in the adjacent file. It is one of the duplicated language tables BossConsole#75 is about, and #358 did not reach it: that PR consolidated the host and out-of-process language-ID tables, while the icon layer kept its own. The copy had already drifted, which is the failure mode #75 describes. Eleven extensions LanguageIcons has icons for were missing from the gate and fell through to the generic file icon: astro, eslintignore, eslintrc, gql, graphql, pom, prettierignore, prettierrc, prisma, proto and styl. Nothing reported that; the file just rendered as a blank document while the icon sat one file away. Measured before changing anything: the gate routed 108 extensions and LanguageIcons knows all 108, so no extension loses an icon. The gate was a strict subset, so deleting it is additive. `forExtensionOrNull` is the nullable variant that lets a caller ask whether an extension is recognised without comparing against `unknown`, which a real mapping could legitimately return. `forExtension` keeps its total contract and delegates. The 19 explicit branches stay ahead of the delegation because they deliberately disagree with the language mapping: a `.xml` is a config document, `.bat`/`.cmd` take PowerShell's colour, `.env` is a lock, and `jar`/`war`/`ear` are archives carrying Java's colour rather than Java source. `forSpecialFileName` still runs first, which is why `pom.xml` is Maven rather than XML. FileIconsLanguageDelegationTest pins all of it: the fallback for a genuinely unknown extension, a spread of the previously gated extensions, each of the eleven that had drifted, every override that must keep winning, the jar case, and that `forExtension` is still total. 47 tests in the module, 0 failures. Module ktlint and detekt clean; the two existing detekt baseline entries were rekeyed to the renamed function rather than widened. Co-Authored-By: Claude Opus 5 (cherry picked from commit a1c298ef63aeea9c676c2f45022b87007d6258c2) * style(file-icons): fix stale section comments left by the delegation change The 'Shell scripts' comment described branches this change removed, and the empty slot under 'Databases' is where the removed sql branch sat. Comments only; no behavior change. (cherry picked from commit 86a031c6adad92f5b74b4e83dd98b142c3706880) * style(file-icons): drop the value-identical gradle branch and stale docs Addresses the Claude review of #525: - remove the explicit "gradle" branch from forFile: it was value-identical to LanguageIcons' own mapping, i.e. exactly the duplication this PR removes; gradle files now resolve through the forExtensionOrNull delegation, pinned with a non-special name (libs.gradle); - the build.gradle assertion tested forSpecialFileName, not the removed branch; the xml-vs-maven override is now pinned directly; - move the Apple property list comment back above the plist branch and drop the orphan blank line at the Windows batch comment; - delete the orphaned KDoc left by the forExtension rename; - prune the baseline entry for the png line that is a block body now. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(plugin-store): stop the update check mis-ordering versions it cannot parse (#539) * fix(plugin-store): stop the update check mis-ordering versions it cannot parse `PluginRepositoryManager.isNewerVersion` split a version on "." and dropped any segment that was not a bare integer. Dropping rather than zero-filling shifts every later segment into the wrong position, so the comparison was made against the wrong field. Two cases a user would notice: "1.0.0+build.7" parsed as [1, 7]. Compared against an installed [1, 0, 0] the 7 landed in the minor position and won, so the store offered an update to the version already installed, on every check. SemVer section 10 says build metadata is ignored for precedence. An installed "1.0-beta.5" parsed as [1, 5], so the 5 sat in the minor position and beat the 2 of a genuine "1.2.3" release. The real update was never offered at all. The codebase already has one answer for this. SemanticVersion describes itself as the sole version-comparison primitive for plugin update and floor checks, and PluginUpdateManager uses it. This was the third comparator and the only one that disagreed with it; across a small matrix of realistic version strings the two disagreed on 32 of 156 ordered pairs. plugin-dependency has no project dependencies of its own, so taking it as a dependency here cannot introduce a cycle, and plugin-updater already does it. One behaviour is preserved deliberately rather than inherited: a plugin whose installed version cannot be parsed is still offered an update. It is already in a broken state, and refusing every future update would strand it there. An unparseable candidate is never offered, because nothing can be said about it. Ten tests, of which five fail against the previous implementation. Co-Authored-By: Claude Opus 5 * docs(plugin-repository): correct version-shift comments and cross-reference the two comparators Addresses the Claude review of #539 (comments and one log line; no behaviour change): - the old code read '1.0.0+build.7' as [1, 0, 7] (the 7 in the PATCH slot, not the minor) and '2.0.0-rc.1' as [2, 0, 1] returning true on 1 > 0 at the third position; the test and KDoc comments now say so; - the test KDoc now names which cases are regression tests and which are guards - most also pass against the old comparison; - both isNewerVersion KDocs now cross-reference each other and state the deliberate fail-open vs fail-closed divergence on unparseable installed versions; - checkForUpdates documents that it has no production caller today (Toolbox path is PluginUpdateBridge -> PluginUpdateManager) and logs when a candidate is refused because its store version is not parseable, keeping 'no update offered' and 'we could not tell' distinguishable. * test(plugin-repository): close out the second Claude review of #539 Addresses the re-review of 20b7e6ff5: - the test KDoc listed six regression cases, but 'a release is newer than its own pre-release' passes against the old comparison too ([1,2,3] vs [1,2] decides at the third position both ways) - it is a guard, as the PR body already said; the list now names the five real regressions; - the checkForUpdates KDoc claimed the function is 'pinned by tests' while nothing called it; it is now driven end to end by two new tests (a newer store version is offered; an unparseable store version is never offered), which also cover the new offerUpdateIfNewer helper and its debug log; - the KDoc now states the honest reason the callerless function is kept: the repository layer should answer the same question the updater does if the paths are ever merged. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(downloads): stop a hostile file name throwing out of the download handler (#540) * fix(downloads): stop a hostile file name throwing out of the download handler FileNameSanitizer.sanitize runs on the name the download source chose, so its input is whatever a server puts in Content-Disposition. Two inputs got the wrong answer. A long extension threw. Nothing bounds how long an extension is: it is whatever follows the last dot. A name of "a." plus 300 characters gives an extension of 301, and subtracting that from the 255 limit gave a negative budget that reached take(), which rejects a negative count and throws out of the handler. The extension is now abandoned when it cannot fit, rather than shortened, because a truncated extension is not the file's type and inventing one is worse than having none. A name of only dots came back ending in a dot. For ".", ".." or "...", the extension computed in step 4 is a lone ".", and the "ensure at least one character" fallback appended it raw to produce "download." That re-created exactly the trailing dot step 5 removes, and a trailing dot is what is invalid on Windows. Truncation could re-expose one too, so the rule is re-applied to whatever the cut produces. The file had no tests. Eleven are added, four of which fail against the previous implementation. They also pin the behaviour that was already right, so the Windows device names, the traversal reduction and the control-character handling cannot regress silently. Verified against the local ktlint and detekt gates. Co-Authored-By: Claude Opus 5 * fix(downloads): abandon an oversized extension instead of truncating it Review follow-up on @arjun28115's fix: the documented intent is that an extension which cannot fit in the 255-character limit is abandoned rather than shortened (a truncated extension is not the file's type), but the budget <= 0 branch kept a prefix of the extension. Preserve the base name only, cut to the limit, and pin the behavior with a regression test. 12 tests, 0 failures; ktlint and detekt clean. * fix(downloads): recompute the cut's extension and re-defuse reserved names Review follow-up on @arjun28115's fix: - truncate() took the extension computed before the trailing-dot trim. For exactly the hostile shape this PR targets - a name ending in a dot - that copy is a stale lone '.', so a real '.pdf' that fit was dropped and the download landed extensionless. The extension is now recomputed from the string being cut, as the helper's KDoc always claimed. - The cut's trim can also shrink the base onto a reserved device name step 4 missed: 'CON ' (space before the dot) is not a reserved segment, so with an oversized extension the result was bare 'CON', which Windows refuses. The cut result is now defused like step 4. - The device-name test title overclaimed ('whatever their case or extension'); it now names what it covers, pins the defused shape, and includes the trailing-space input the description claimed was tested. 14 tests, 0 failures; ktlint and detekt clean. * fix(downloads): compare a device name at the first dot, as Win32 does The review of 9d04f35 is right and the disposition that closed finding 2 was not. A device name followed by an extension is still the device: Microsoft's naming rules say NUL.txt and NUL.tar.gz are both equivalent to NUL, and this file already agreed, since step 4 rewrites CON.txt to _CON.txt. The check compared the segment before the LAST dot, which leaves two holes that are the same underlying fact. Multi-dot names were missed entirely. "CON.tar.gz" was compared as "CON.tar", which is not reserved, so it passed through untouched. Truncation could manufacture a device out of a name that was not one. "CONSOLE." followed by 251 characters is not reserved at step 4. Cutting it to fit the limit left "CON." and the rest, which Windows resolves to the console, so the download writes nowhere and no file appears. The defuse added in 9d04f35 did not catch it, because it compared the whole cut rather than the base, and only the budget <= 0 branch could ever match. Both close by comparing the segment before the first dot, in one helper used by step 4 and by the cut. Trailing spaces are trimmed before the comparison for the same reason: Windows ignores them. Defusing prepends one character, which would push a full-length result to 256. The cut is therefore redone one character shorter when that happens, which is always enough because the prefix is exactly one character. Three tests, all of which fail against 9d04f35. The last asserts the invariant rather than a case: no input in the list comes back as something Windows would resolve to a device. The title of the existing device test said "with a single extension", which was accurate before this and is not now, so it has been corrected rather than left to mislead. 17 tests, ktlint and detekt clean. Co-Authored-By: Claude Opus 5 * test(downloads): pin the retry-that-defuses cut and the CONSOLE redo shape Closes the two test-only items from the third Claude review of 40700c137: - new test for the one path where a defused cut survives into the returned value: the 255 cut and the 254 redo both trim onto CON, both defuse, and the result lands exactly at the 255 limit; - the CONSOLE. + 251x test now pins the full redo shape (CO. + 251x) instead of a non-device check that would also accept a fallback name; - the padded-name test comment now says step 4, not the cut, defuses it; - truncate's comment states the cutTo limit + 1 bound that makes one redo always enough, and the step 4 extension local says it serves the step 6 fallback. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(browser): prevent memory leak by unifying browser teardown paths (#502) Unify external browser closure with idempotent full teardown. Includes reviewed EDT dispatch, unconditional unregister and regression guards. Original contribution: Anshul Pundhir (@pundhiranshul). Review-agent hardening is documented in PR #502. Co-authored-by: pundhiranshul * fix(runner): honor main-panel rerun delay and document unsupported settings (#486) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and name… * fix(repository): a cancelled store call is not a network failure (#531) * fix(repository): a cancelled store call is not a network failure; apply downloadPlugin's rule to every store call * test(repository): pin propagation and the ratePlugin path; fix stale call-site comments Claude review follow-up on #531, on top of aryanbajaj868's fix: - RemotePluginRepositoryCancellationTest: ratePlugin is driven by the held server too (it refuses before a request without an access token; a.b.c is a structurally valid three-part token with no admin claim), and every store call is now asserted to propagate the caller's CancellationException out of the method, not merely to stay out of the log - a swallowed cancellation is also no ERROR line, so the log assertion alone cannot see that half. - onStoreFailure moved into the Helper Functions section; the test server's single dispatch thread is documented. - Three call-site comments (StoreMissingDependencyInstaller, PluginStoreVersionBridge, PluginLoadGateRecovery) restated their rationale: the bundled repository now rethrows a caller's cancellation, but the PluginRepository interface permits either shape, so the flattened handling and the rethrow guards stay. --------- Co-authored-by: Shivang * Restrict completed_authentications to the service-role Edge Function (#536) * Restrict completed_authentications to the service-role Edge Function completed_authentications holds the freshly minted access_token and refresh_token for the cross-device (QR) login handoff. Its anon policies were each guarded only by `session_id IS NOT NULL`, and session_id is NOT NULL on every real row, so all three were `USING (true)` written the long way. Nothing required the caller to know a session id, so the "unguessable UUID acts as a bearer token" model the surrounding comments describe was never enforced. With `GRANT ALL ON TABLE ... TO anon` and an anon key that is public by construction (it ships in the desktop app and this repo is open source), that was reachable by anyone who can reach PostgREST: 1. Token harvest. Selecting the table returns every in-flight login's access and refresh token. Polling faster than the legitimate desktop wins the race to any QR-logging-in user's session, which is account takeover. 2. Denial of service. A single DELETE wipes all in-flight logins. 3. Session fixation. anon INSERT pre-seeds a victim's session_id with attacker-controlled tokens, which the victim's desktop imports as its own. Reported as BossConsole#528 by @Rushikeshiitb, including the fix shape used here. Safe because no client reaches the table directly any more, which I verified rather than took on trust: grepping the desktop source for completed_authentications returns nothing outside build output, and the desktop polls GET /passkey/auth/status/{sessionId} instead (SupabaseApiClient.kt:103). Every read and write happens inside the passkey Edge Function, whose Supabase client is built with SUPABASE_SERVICE_ROLE_KEY (passkey/index.ts:36). service_role bypasses RLS, so the anon and authenticated policies and grants are dead code on the live path. The authenticated policies go with the anon ones because the flow does not use them either, and leaving a role with standing access to a table of bearer tokens is the condition that made this exploitable. Follows 20260910120000_restrict_plugin_downloads_rls.sql: revoke the base-table privileges as well so the gate does not rest on RLS alone, including from PUBLIC, since a grant there would survive revoking the two named roles. The six policy names were checked against 20251023000013_rls_policies.sql programmatically. A mistyped name in DROP POLICY IF EXISTS is a silent no-op that would leave the hole open, and the pgTAP test asserts absence rather than trusting the drop. Co-Authored-By: Claude Opus 5 * test(db): carry over service_role privilege assertions from #530 BossConsole#530 by @Rushikeshiitb asserted that service_role keeps every verb on completed_authentications after the lockdown; this suite only checked the surviving policy. Merge the two companion submissions by adopting those four assertions (plan 11 -> 15) so a too-aggressive revoke of the service-role grant fails the pgTAP suite. * fix(db): revoke client EXECUTE on the expired-row cleanup RPC cleanup_expired_completed_authentications() is granted to anon and authenticated at 20251023000014_grants.sql:240-241, but no client code calls it: it returns void, only deletes rows past expires_at_timestamp, and after the table revoke it cannot execute its DELETE as a client anyway. Revoke the client EXECUTE so no client role keeps any handle to the token table, and pin it with three pgTAP assertions (plan 15 -> 18). Answering the open question raised in the PR description. * fix(db): also revoke PUBLIC EXECUTE on the cleanup RPC PostgreSQL grants EXECUTE on functions to PUBLIC by default, so the previous REVOKE ... FROM anon, authenticated left =X/postgres in the function ACL and has_function_privilege kept returning true for both client roles (pgTAP tests 16-17 failed on the previous head). Revoke from PUBLIC as well, matching the table revoke; service_role keeps its explicit grant (verified f|f|t in a scratch PostgreSQL 17). * test(db): pin the policy set and the verbs a named REVOKE leaves behind Claude's review of this PR found the two per-role policy checks would pass a policy created without a TO clause (pg_policies records it as roles = {public}, so it applies to everyone yet matches neither client role), that TRUNCATE - the one verb row level security cannot stop - was unasserted, and that authenticated was missing its UPDATE assertion. - Assert the full policy set is exactly the service_role one instead of checking each client role separately; verified on scratch PostgreSQL 17 that an unscoped policy fails the new assertion and passes the old one. - Assert TRUNCATE, REFERENCES and TRIGGER for both client roles, matching the precedent suite plugin_downloads_access_test.sql. - Add the authenticated UPDATE assertion, so both client roles are now covered per verb as the header promised. - Reword the cleanup-RPC comment to lead with the REVOKE EXECUTE as the actual gate and flag the stale SECURITY DEFINER header comment on cleanup_expired_completed_authentications (20251023000003_passkey_functions.sql:221) that made the fallback argument fragile. plan 18 -> 24. Verified all 24 assertions on a scratch PostgreSQL 17 replica of the post-migration state. --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang Co-authored-by: Shivang * fix(security): stop two audit logs accepting forged rows from any client (#538) * fix(security): stop two audit logs accepting forged rows from any client `secret_access_log` and `plugin_api_key_logs` are the audit trails for secret access and for plugin-store API key usage. Both had an INSERT policy of `WITH CHECK (true)` with no `TO` clause, and a policy with no `TO` clause applies to PUBLIC, which includes anon and authenticated. Both tables are writable by those roles. secret_access_log has an explicit `GRANT ALL ... TO anon, authenticated`. plugin_api_key_logs has no explicit grant, but 20251023000014_grants.sql sets `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon, authenticated` and that table is created later, so it inherits it. The anon key ships in the desktop client, so anybody could append arbitrary rows to either log. secret_access_log.user_id was unconstrained, so a row claiming another user viewed or shared a secret could be written at will, and the table's own SELECT policy then shows it to the named user and to every admin as though it were real. A genuine access event could equally be buried under forged entries, which is the failure an audit log exists to prevent. Neither policy comment described this. The plugin_api_key_logs policy is named "Service role can insert API key logs", which is what it was meant to be and not what it did. No legitimate writer is affected. Every function that writes secret_access_log inserts auth.uid() as user_id, which the new predicate permits. plugin_api_key_logs is written only by log_api_key_action(), which is SECURITY DEFINER and granted to service_role alone, so it bypasses RLS and needs no INSERT policy at all. Nineteen pgTAP assertions cover the fix, with privileges checked per verb rather than with a single ALL check, because has_table_privilege(..., 'ALL') is true only when every verb is held and one re-granted verb would pass an ALL-shaped assertion while leaving the hole open. Co-Authored-By: Claude Opus 5 * fix(db): close the definer-RPC door Claude's review opened Claude review found the plugin_api_key_logs half did not close the hole: log_api_key_action() is SECURITY DEFINER owned by postgres, and clients still held EXECUTE on it (PostgreSQL grants EXECUTE to PUBLIC by default, plus the schema-wide default-privilege grants inherited by this later-created function; the explicit service_role grant is additive). Any client could forge API-key history through the RPC even with the table locked. - Revoke EXECUTE on log_api_key_action() from PUBLIC, anon and authenticated; service_role (the only caller, via the plugin-store Edge Function's service-role client) keeps it. - secret_access_log: revoke authenticated INSERT too - every writer is SECURITY DEFINER owned by postgres and bypasses RLS without client table privilege, so a scoped client INSERT could only forge self-attributed rows. Drop the INSERT policy instead of recreating it scoped, since no client statement can insert anyway. - Tests: assert the RPC EXECUTE rejections/retention (3), flip the authenticated INSERT assertion, replace the three policy-shape assertions with an absence check (plan 19 -> 20). * fix(security): close the second RPC and the verbs a named REVOKE leaves behind Builds on kshivang's 2328a13, which closed the definer-RPC door the review found. Three things the review named are still open after it. update_api_key_last_used() has the same shape as log_api_key_action(): same file, SECURITY DEFINER, EXECUTE never revoked, service_role grant additive. A client can still stamp last_used_at on any key id, which corrupts the same audit story from the other end and makes a dormant key look live. Revoked, with the same three assertions the other RPC now has. Revoking named verbs leaves the rest of the original GRANT ALL: TRUNCATE, REFERENCES and TRIGGER. TRUNCATE is the one that matters, because it is not subject to row level security at all, so no policy can be its backstop. anon already lost these to the REVOKE ALL; authenticated did not. The per-verb argument in the test header was applied to one table. For plugin_api_key_logs only anon INSERT, authenticated INSERT and authenticated SELECT were asserted, so a re-grant of anon SELECT, UPDATE or DELETE, or of authenticated UPDATE or DELETE, failed nothing. That is the precise failure the header set out to prevent. The API key log policy's command is pinned too: a policy recreated as FOR ALL under the same name and role would otherwise pass every other assertion while granting far more. Six assertions are behavioural rather than catalogue-shaped, following plugin_downloads_access_test.sql: set the role, attempt the write, assert 42501. Each is one of the three defects in the migration header, tested directly rather than inferred from a privilege bit. Plan 20 to 39. I still cannot run supabase test db locally: no Supabase CLI, psql or Docker on this machine. CI's Database job remains the real check. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang * refactor(startup): split monolithic main.kt into modular startup phases (#450) * fix(microkernel): resolve startup failures and stabilize service lifecycle * fix: CI workflows * refactor(startup): split monolithic main.kt into modular startup phases * fix: CI and workflows * fix(startup): preserve failure notices and safe descendant teardown * test(startup): pin forwarding and shutdown extraction contracts * fix(kernel): report a restart cap once per failed generation * style(startup): format extraction regression tests * fix(startup): keep restored comments scoped to window composition * fix(startup): separate cancellation from shutdown timeout * fix(startup): acquire instance ownership before starting kernel * fix(startup): release ownership on construction failure and retry partial natives * fix(microkernel): retain repair notices and await forced process exit * test(startup): simplify interrupted native stream fixture * fix(startup): publish extracted native files atomically * fix(startup): coalesce retained diagnostics and normalize saved mode writes * fix(startup): unify effective mode gates and bound operator diagnostics * fix(settings): retain pending process mode and report save failures * fix(settings): synchronize pending mode across windows and explain unavailable builds * fix(settings): let shared preference refresh own displayed mode * fix(settings): use themed save errors and strengthen availability coverage * fix(build): restore store synchronization imports after dev integration * fix(settings): show running readiness and load mode support asynchronously * fix: failed menu save in BossWindow * fix(startup): integrate current dev, restore merge-dropped startup wiring The prior dev merge reintroduced closed #419 content on top of current dev, leaving duplicate single-instance blocks, a parallel BOSS_MODE preference writer, and dropped shutdown-registration wiring. - Remove the duplicate single-instance block in main.kt; keep the CliBootstrap.forwardToExistingInstance path - Restore the Phase 5 shutdown-hook registration (ShutdownSequence.register { kernelBootstrap } + lock-acquired log) that the merge dropped - Drop the branch's parallel EnvVars/ConfigLoader mode writers in favor of dev's canonical MicrokernelModePreference (#481); AdvancedSettings is byte-identical to dev - Route CLI deep-link forwarding through dev's forwardDeepLinkWithRetry (action links: 1 attempt; others: 3) without short-circuiting a multi-file selection after one failure - Restore awaitForcedExit waits + takeUnless{isAlive} unregister guards in OutOfProcessPluginSpawnerImpl cleanup/terminate (load-bearing for reaping; dev keeps the window-scoped hashed processId) - Remove dead microkernelMenuLabel; update CliBootstrap/EnvVars tests and AGENTS.md BOSS_MODE section to match dev's model * fix(startup): close the env_vars reader split and review follow-ups Env vars: - One shared normalization (EnvVarsFormat, commonMain) for the env_vars file: both in-repo readers (ConfigLoader's BOSS_MODE tier and MicrokernelModePreference's toggle reader) now parse identical line semantics, so Settings and the runtime gate can no longer state opposite modes for one file. export KEY=value is now recognized by both (shell convention; env_vars doubles as the secret-manager plugin's key file), and an indented '# BOSS_MODE=KERNEL' is a comment in both readers. - ConfigLoader.loadEnvVars widens its catch to Throwable: an ExceptionInInitializerError or NoClassDefFoundError from BossDirectories' own static init can no longer escape ConfigLoader's and poison every later getConfig call. - Restored dev's per-attempt 'Open request forwarding completed' INFO log in CliBootstrap (the extraction had left only the failure WARN). - A fully successful KERNEL spawn is logged, not toasted: only missing JARs or failed spawns report to the startup notice queue (previously every healthy launch showed a 12s developer-worded toast). Docs: - AGENTS.md: the env_vars priority paragraph and the microkernel toggle section no longer contradict each other - the toggle now activates the next launch via the env_vars tier, applied on restart, with the latched startup snapshot still as the 'restart required' comparand. - startupEnabledLatched KDoc updated to the post-env-vars-tier reality. Tests: - EnvVarsTest: export-prefixed and indented-comment line pins. - MicrokernelModePreferenceTest: indented-comment reads as disabled; new test asserts the settings reader and ConfigLoader reader agree line by line across the sample matrix. - ServiceStartupSummaryTest: the notice gate (no toast on a clean spawn, toast on missing/failed services). --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges (#505) * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges BossConsole#53 built a ServerInterceptor (ProcessIdentityInterceptor / ProcessTokenRegistry) that verifies which process is calling the kernel's out-of-process IPC surface, independent of anything a request body claims - but only wired it into PluginUIServiceBridge. The other fourteen service bridges, documented in that interceptor's own KDoc as "none of them authenticated today", still trust every caller unconditionally. SecretServiceBridge and RoleManagementServiceBridge are the two where that actually matters: the whole password vault (list/search/create/update/ delete/share/unshare) and RBAC (roles, permissions, grants) had no caller attribution at all. Any out-of-process plugin that can reach the kernel's local IPC socket could read, mutate or share every secret, or mint and revoke permission grants, with nothing to tell one plugin's calls from another's. Both bridges now require AUTHENTICATED_PROCESS_ID on every RPC - reads included, not only mutations, since an unattributed read of the vault list is exactly the exposure this closes - and refuse with PERMISSION_DENIED before the request ever reaches the underlying provider when there is none. Every mutating call also logs the verified caller identity, so a compromised or rogue plugin's activity is attributable after the fact. Every legitimate out-of-process plugin already carries a valid credential in production (ChildProcessBootstrap attaches ProcessTokenClientInterceptor whenever BOSS_PROCESS_TOKEN is set, which ProcessSpawner sets for every child it spawns), so this closes the gap without changing behavior for any real plugin. Tests exercise both bridges over a real gRPC server and real client, mirroring PluginUIServiceBridgeTest's own harness: an authenticated caller succeeds, an anonymous one is refused, and - the assertion that actually matters - the underlying provider is never invoked on a refusal. * test(kernel): pin the identity gate on late-registered bridges and close the updateSecret gap - SecretServiceBridgeTest: updateSecret gets its own anonymous-refusal test, matching the vault mutations it already covered. - SecretServiceBridgeTest: a service registered on an already-started server (BossIpcServer's late-service fallback registry, as KernelBootstrap.registerPluginServices does) is still gated by ProcessIdentityInterceptor - anonymous calls are refused before the vault is reached, token calls go through. This pins the registration order (fallbackHandlerRegistry before intercept) the production path relies on. - KDoc on both authenticatedCallerOrRefuse helpers: the AUTHENTICATED_PROCESS_ID snapshot is correct for unary RPCs; a streaming bridge must read CURRENT_IDENTITY instead (see PluginUIServiceBridge.streamUI). * test(kernel): tighten RBAC/secret refusal assertions, add revoked-token coverage, fix stale KDocs RoleManagementServiceBridgeTest: getRolePermissions, validateRoleName and validatePermissionName were asserted only as "some StatusException", with no counters on FakeRoleManagementProvider at all - a bare assertFailsWith would pass just as happily on UNAVAILABLE or UNIMPLEMENTED, contradicting the class's own KDoc claim that every refusal test proves the provider was never reached. Added the three missing counters and now assert PERMISSION_DENIED plus a zero counter on every refusal in both bridge test files, not just the two "an anonymous caller is refused" tests. SecretServiceBridgeTest: the late-registration test called tokenRegistry.issue(DEFAULT_PROCESS) for the same id setUp already issued for - issue() replaces and invalidates the previous token by design, so this silently killed the credential the shared `authenticated` stub carries for the rest of the class. Now issues a distinct id. lateServer teardown moved into a finally so a failed assertion doesn't leak a bound server. Moved an orphaned KDoc comment back onto FakeSecretDataProvider (SingleServiceRegistry's insertion had left it dangling above the wrong class). Added a revoked-token test to both files: identityFor returns null for a token the registry no longer holds, the post-reapChildren/post-respawn case, previously untested - every existing refusal test only covered "no credential at all", not "a credential that was live and got pulled". Two stale KDoc facts fixed in boss-ipc: ProcessIdentityInterceptor's comment named PluginUIServiceBridge as the only bridge checking identity (now three of fifteen, with SecretServiceBridge and RoleManagementServiceBridge named); BossIpcServer's tokenRegistry KDoc claimed a null registry "leaves every service's behaviour exactly as it was", which stopped being true for a server hosting either of the two fail-closed bridges - a null registry there means every call refused, not merely unauthenticated as before. * docs(ipc): correct the unauthenticated bridge count in the interceptor KDoc The kernel registers 16 bridges on its ipcServer (15 in KernelBootstrap.registerPluginServices plus PluginUIServiceBridge at KernelBootstrap.kt:475); three authenticate, so thirteen are unauthenticated, not twelve. Per the Claude review's one-word count fix. --------- Co-authored-by: kshivang * fix(cli): multi-window atomic reload, safe dev retention, and schema alignment - DevPluginReloader: implement multi-window preflight check ensuring all live managers can unload before executing any uninstall; enforce LOADED state verification on install; host-side staging retention strictly protects JAR paths actively referenced across any live manager - BossPluginCommand: prune staging history only on ReloadResult.HostOffline; rely on host-side retention when host is online - DefaultPlugin & PluginStoreSetup: swap startup candidates to dev JARs pre-load; remove runtime prioritizeDevPluginIfNecessary; prevent dev JARs from polluting or overwriting installed.json - PluginBuildProbe: skip hooks.record for dev JARs to avoid dev paths in installed.json - PluginManifest & PluginScaffolder: adopt requiredPermissions as primary property matching host schema with legacy permissions alias - PluginValidator: reject archives containing root plugin.json requiring META-INF/boss-plugin/plugin.json; eagerly cache entrypoint class bytes in ArchiveData to eliminate closed-ZIP ZipException in bytecode fallback - Tests: add DevPluginReloaderTest covering multi-manager atomic preflight and 4-stage retention preservation across multiple managers; update PluginValidatorEvalTest and PluginManifestTest * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and nam… * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog (#469) * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog McpPolicyEngine.setToolPolicy already writes a durable ALLOW/ASK/DENY rule to ~/.boss/mcp-tool-policy.json and takes effect immediately - it was fully built and tested, but the only caller was this repo's own test suite. AGENTS.md's own governance section names the gap plainly: "Persistent rules currently require editing ~/.boss/mcp-tool-policy.json and restarting." The interactive approval dialog for ASK-governed tools offered only "Approve Once" and "Always this Session" (in-memory, cleared on restart) - there was no way to tell BOSS "never ask about this tool again" without hand-editing JSON. - McpApprovalDecision.Approved/Denied gain a persistPolicy flag, threaded through McpApprovalBus.approve/deny (both additive, defaulting to false - every existing call site is unaffected). - McpToolRegistryCore.authorizeInvocation calls policyEngine.setToolPolicy(toolName, ALLOW/DENY) when persistPolicy is set, recording two new McpApprovalDisposition values (PERSISTENTLY_ALLOWED / PERSISTENTLY_DENIED) in the audit ledger so it's visible which of the three scopes - once, session, persistent - an operator actually chose. A persisted ALLOW makes the existing session-trust call redundant (policyFor checks the configured rule first), so the registry does not also call trustForSession when this is set - one less write, same result. - McpApprovalDialog gains "Always Allow" / "Always Deny" actions, visually secondary to the three existing per-call actions since they are the more consequential, less common choice. Scope: this is the interactive path only. The policy file itself, its fail-closed-on-unreadable behavior, and the kill-switch/RBAC layers upstream of policy resolution are untouched. 8 tests in McpGovernedInvocationTest (2 new) cover persisted ALLOW and DENY end to end: the approval/denial itself, the ledger disposition, and that a second call to the same tool no longer suspends for approval - confirming the rule actually took effect, not just that it was written. ktlintCheck, detekt and the full mcp/app/dialogs test packages all green. * fix(mcp): verify durable approval writes and preserve revocations * feat(mcp): let an operator inspect and revoke a persisted tool policy The review's remaining finding: after "Always Allow" persists a rule, there was no way back except hand-editing ~/.boss/mcp-tool-policy.json and restarting - the new caption and AGENTS.md text explained that, but disclosure is not a reversal control, and "Revoke MCP session trust" only ever cleared in-memory grants, never a persisted rule. McpPolicyEngine.revokePersistedPolicy(toolName) is the operator-facing undo for setToolPolicy's persistent scope: resets the tool to ASK and clears session trust for it in the same call, since policyFor checks session trust before a non-DENY configured rule - revoking only the persisted half would leave a tool that happened to hold both still silently auto-allowed. Returns whether the write actually succeeded, so a failed revoke is reported rather than the UI showing a change that never reached disk. McpPolicyManagerDialog lists every persisted rule (tool name, ALLOW/ DENY, a per-row "Reset to Ask") and shows an inline error for a row whose reset failed to save. Wired into the bottom bar next to the existing session-trust revoke, behind a button that only appears once a rule exists - nothing new to look at when there is nothing to revoke. Not exposed as an MCP tool: this is a human-only control reachable by clicking, never callable by an agent on its own behalf. New tests cover the exact validation the review asked for: a persisted ALLOW revoked from the API resets to ASK and survives a reload from disk; revoking also clears session trust so the next call asks again even when both grants were held; an unrelated tool's DENY rule is untouched; and a failed write is reported through the same fault channel setToolPolicy already uses, not silently swallowed. * fix(mcp): make policy revocation actually remove the rule, off the UI thread revokePersistedPolicy previously rewrote a revoked rule to ASK instead of removing it, so the bottom-bar count never dropped and the policy manager dialog kept listing "revoked" tools with a no-op Reset button; under a hardened defaultMutatingAction=DENY config the leftover ASK was also weaker than the operator's own default. Removing the key instead fixes all three. The revoke write also ran synchronously on the Compose UI thread; onRevoke is now a suspend callback launched from the dialog's own coroutine scope, with the actual engine call wrapped in withContext(Dispatchers.IO) at the call site. A DENY row's reset now needs a second tap to confirm, since it is the one control in this dialog that raises standing privilege rather than lowering it. Stale operator-facing copy pointing at hand-editing the policy file was updated to point at the in-app dialog instead, in both the approval dialog and AGENTS.md's governance section. Left as a documented limitation rather than fixed: a queued approval answered "Always Allow" after the same tool's rule was freshly revoked can still re-persist it, since the existing preserveDeny guard only protects against a newer DENY, not a newer revocation. Closing that needs a revocation generation/timestamp compared inside the policy engine's lock. * fix(mcp): invalidate queued approvals when a saved policy is reset --------- Co-authored-by: Shivang * feat(plugins): add health and recovery center (#454) * feat(plugins): add health and recovery center * fix(plugins): retain recovery feedback and use lifecycle delegate * style(plugins): satisfy health recovery quality limits * fix(plugins): report unavailable recovery and observe sandbox disables * style(plugins): keep health dialog within function limit * style(plugins): remove unused sandbox state helper * style(plugins): format sandbox state lookup chain * fix(plugins): route watchdog recovery through safe reload * fix(plugins): finish sandbox teardown before recovery reload * fix(plugins): complete recovery teardown after caller cancellation * fix(plugins): restore panels when pre-lock uninstall is cancelled * fix(plugins): propagate cancellation before destructive uninstall * fix: prevent disabled plugin sandbox restart * fix: satisfy sandbox recovery static checks * style: format sandbox restart recovery * fix: close sandbox restart admission races * test(plugins): pin disable races and clarify cancelled uninstall outcome * test(plugins): configure budget race fixture explicitly * style(plugins): format sandbox race regression fixture --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): #429 follow-ups: one offer filter, root-named failures, matching cancellation contracts (#483) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails t… * feat(plugins): route a missing-dependency prompt to the window that raised it (#465) * feat(plugins): route a missing-dependency prompt to the window that raised it MissingDependencyPrompt is delivered over a Channel: exactly one window's collector receives each prompt, but not necessarily the one whose install raised it. The class's own KDoc named this as a known, deliberately unbuilt limitation - "routing back to the reporting window would need the prompt to carry a window id and the collector to be able to decline one without consuming it... not built, because a single window is the overwhelmingly common case." This builds exactly that, for the case where it does happen: a person answers "Install" in the window they're looking at and nothing changes there, because a different, idle window happened to collect it. - MissingDependencyPrompt gains windowId: String? = null, resolved via WindowFocusManager.resolveActionableWindowId() at the two report call sites where a window is unambiguously the actionable one right now (MissingDependencyReporter.report, a user-initiated install-completion path; MissingPluginOffer.offerIfMissing, a direct button click) - the same best-effort "whichever window is actionable" idiom this codebase already uses for deep links and CLI commands, not a new standard. - shouldClaimMissingDependencyPrompt (PluginDependencyResolution.kt) is the pure decision a wrongly-chosen collector uses: claim it if there's no reporting window to prefer, if this window IS the reporting window, or if the reporting window has simply closed (a prompt must never wait forever for a window that's gone). Kept pure and tested the same way shouldShowMissingDependency already is. - The collector in BossAppEventBusEffects.kt calls this before showing a prompt; when it says no, the window re-offers the prompt to the bus (a short delay first, so two non-target windows can't trade it back and forth in a tight loop while the real target's own collector is still starting up) instead of displaying it. - WindowFocusManager.isWindowOpen and resolveActionableWindowId are promoted onto the expect/actual contract so commonMain code (where MissingDependencyPrompt and its bus already live) can ask both questions without a desktop-only reference. Scope: PerformanceState.performancePanelPrompt (the status-bar indicator's offer) is left at the windowId = null default - it resolves its plugin manager via DynamicPluginManager.anyActiveManager() with no direct window context available, and threading one through would mean unrelated refactoring of that call chain rather than this feature. Falls back to today's behavior for that one path only, same as every prompt did before this change. 5 new tests (MissingDependencyPromptRoutingTest) cover the decision function directly: no reporting window, own window, wrong window with the target still open, wrong window after the target has closed, and the single-window case. Full desktopTest suite, ktlintCheck and detekt all clean (one pre-existing, unrelated Windows-symlink-privilege test failure in DesktopFileScannerTest, confirmed present on unmodified main). * fix(plugins): return routed prompts before collector suspension * fix(plugins): replace the bounded channel with a lossless prompt registry Review of this PR (batch-4) proved two real bugs in the bounded-channel delivery PluginDependencyBus used: reporting one prompt, receiving it, admitting four unrelated ones, then re-reporting the first refused it - the channel's four-slot capacity had nowhere left to put it, and it was gone. Separately, freeing the dedup guard the instant a prompt was handed to any collector (rather than when a collector was actually done with it) let a second, independent report for the same key be admitted as a competing entry while the first was still in transit. Replaces the Channel(capacity=4) + separate dedup set with one LinkedHashMap, guarded by a single lock so admission (report) and claiming (claim) are each one atomic step. A map has no capacity to overflow, and a key stays reserved from report() until claim() actually removes it - there is no gap for a concurrent report to slip into. missingDependencies now broadcasts every pending prompt to every open window; each window decides independently (via the existing shouldClaimMissingDependencyPrompt) whether to claim(), and a losing racer simply does nothing further. This also removes the reoffer mechanism entirely: a window that is not the right audience for a prompt now just leaves it alone rather than report()-ing it back with a delay, which was the "unbounded routing hops" the review flagged (one wait-and-retry loop per rejected prompt per window). A single bus-owned periodic re-broadcast covers the one case no report() fires for: the preferred window closing with nothing else to wake collectors. New/rewritten tests reproduce the review's own adversarial scenario directly (a prompt survives being re-reported while four distinct prompts are also pending) and prove claim() is atomic under concurrent callers, alongside the existing dependency-resolution and pure-function routing tests, all updated for the claim-based consumption model. * test(plugins): fix a test broken by the claim-based bus consumption model PerformancePanelPromptTest's "forcing twice does not queue two dialogs" assumed the old design's receive-consumes semantics: a plain missingDependencies.first() no longer removes anything from the bus, only claim() does, so the test's second first() call was seeing the same still-pending prompt again instead of timing out. Missed in the original PluginDependencyBus rewrite because this test lives outside the components.plugin package the rest of the affected suite is in - caught by CI (ubuntu/macos/windows build-test all failed on it), not by the targeted test runs during that change. * fix(plugins): re-resolve a disposed manager's installer, fix a claim-timing hole Addresses the three findings from the claim-based-queue review round: - The closed-target fallback (shouldClaimMissingDependencyPrompt's !targetWindowOpen clause) lets a different window claim a prompt once its preferred window has closed - but the prompt's installer was still bound to that closed window's DynamicPluginManager, which disposeWindow() has already cancelled and uninstalled everything from. Install then loaded into a dead manager and reported success having put the plugin nowhere anything renders - the exact #188 symptom this repo's own release notes describe. installerFor now re-resolves to DynamicPluginManager.anyActiveManager() at use time for both the isInstalled check and the actual install, falling back to the originally-bound manager only when nothing at all is live - the same fix anyActiveManager's own KDoc already documents for the home screen's install tile, applied here too. - claim() ran before the isInstalled check's IO stat, so cancelling that coroutine mid-stat (window closing, the effect restarting) permanently lost an already-claimed prompt: it was off the bus with nowhere else recorded. Moved the claim to immediately before the state assignment, after present/show are both decided - a non-suspending sequence from there, so the only remaining window is the documented, accepted one where the dialog itself is already up. The cost is a duplicated isInstalled stat in the rare case two windows reach the check on one prompt at once, which claim() returning false for the loser already handles correctly. - The bus's wake signal (changed, a MutableSharedFlow(replay = 1)) had no extra buffer, so tryEmit returned false - discarded, since report() never checked it - whenever a collector hadn't yet consumed the replayed value, i.e. whenever any window was mid-dialog. That is the common case for a second missing dependency, not a rare one, so the periodic ticker (documented as covering only a closed target window) was doing the real signalling work for the routine case too. Nothing was ever lost - pending still held every prompt - but a second dependency could sit for up to a second before its rescan. extraBufferCapacity = 1 with DROP_OLDEST makes tryEmit always succeed; conflating repeated "go look" signals is exactly right for a rescan trigger. Also: renamed a test that implied claim() was proven atomic under real concurrency - runTest serialises both async bodies, so it never was, and the assertion it actually makes (a second claim of the same prompt fails) is what the new name says. Added a test for the ticker's periodic re-broadcast of a still-pending, unclaimed prompt, which had no coverage at all. MISSING_DEPENDENCY_RESCAN_INTERVAL_MS is internal now (from private) so that test - and any future one - can pin the real interval instead of a copy of it, matching this repo's own precedent for timing constants. Five doc sites (AGENTS.md and four KDoc cross-references) that still described the deleted channel/reoffer design are corrected to describe the current claim-based map registry instead. * fix(plugins): retire suppressed prompts and preserve live installer ownership --------- Co-authored-by: Shivang * fix(secrets): preserve hostnames in the host matching utility (#482) * fix(browser): catch native disposal exceptions to prevent incorrect sandbox disables Wraps native JxBrowser event listeners to prevent ObjectClosedException from escaping into the sandbox manager. Previously, unexpected browser disposal could be interpreted as a sandbox failure and consume the plugin restart budget. * fix(security): tighten browser secret domain matching * Wrap matcher regression tests to satisfy Detekt * Keep full hostnames visible in saved-secret display labels --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shivang * feat(settings): explain experimental Microkernel Mode before enabling it (#481) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names e… * fix(shortcuts): trigger key bindings on key release instead of key press (#492) * fix(shortcuts): trigger key bindings on key release instead of key press * fix(shortcuts): invoke a bound action on key-up, not key-down (#490) A shortcut's action ran from the KEY_PRESSED event, so holding a chord past the OS auto-repeat threshold fired it repeatedly (Cmd+N held down kept creating tabs), and the action always ran while the keys were still physically held. AWTKeyboardInterceptor.install() now recognizes a matching chord on KEY_PRESSED - claiming it (so it still doesn't leak to BossTerm/a browser while held) - but only arms it as a PendingShortcut. The bound action fires exactly once, from handleKeyReleased, when the primary key's own KEY_RELEASED arrives; a repeat KEY_PRESSED for the same key stays claimed without re-arming or re-firing. Releasing a modifier by itself can never match, since a modifier key is never allowed to arm in the first place. Focus moving to another window (or away entirely) clears any armed-but-unfired chord via a focusedWindow property listener, and uninstall() clears it too. dispatchAction gained a `perform` flag (default true, so every existing call site is unchanged) so handleKeyPressed can ask "would this dispatch" - reusing the exact same gate each action already had (canStepTabs, activePanelTabCount, panelCountState, ClosedTabHistory) - without performing the trigger, and handleKeyReleased later asks for real. This is one function answering both questions rather than two that could drift, at the cost of a narrow, documented race if the gate's answer changes in the few hundred ms between press and release. PluginShortcutRegistryImpl.dispatch has no such probe, so a matched plugin default arms optimistically and is confirmed for real at release. KeyboardShortcutInterceptor.kt (a second matcher against the same KeymapMatcher, not wired to any production caller today) gets the same arm-on-KeyDown/emit-on-matching-KeyUp treatment for consistency. New tests drive handleKeyPressed/handleKeyReleased directly with synthetic AWT KeyEvents (the same way the existing gate tests drive dispatchAction/dispatchIfCanStepTabs directly), covering: a key-up fires exactly once, a mismatched or modifier release does nothing, a repeat key-down neither re-arms nor re-fires, a gate closing between press and release is honored at release, and cancelling drops an armed chord. * fix: wokflows * fix(shortcuts): validate pending chord lifetime and release ownership * fix(shortcuts): fix MRU wedge, stale-arm swallow, and dropped-chord bugs (#490) Addresses the three substantive findings from review: - MRU tab cycling wedged open when the modifier released before the primary key. Arming (tabCycleActive/tabCycleModifierKeyCode) had moved to handleKeyReleased, where the cycling modifier is no longer guaranteed still down - the OS can deliver the two key-ups in either order, and releasing the modifier first meant arming for a release that had already happened. Extracted armTabCycleIfApplicable and call it from handleKeyPressed instead, where the modifier is guaranteed held (it's what made the chord match). - A lost KEY_RELEASED (swallowed by a native popup, a heavyweight surface, or an OS-level chord grab, with focus never moving so nothing else cleared it) left a stale armed entry that the repeat- claim check matched on keycode alone - so a later, unrelated bare press of the same physical key was silently swallowed, and its key-up fired the shortcut for a keystroke that was never meant to be one. PendingShortcut now records the arming event's four modifier flags, and the repeat-claim check requires them to still match; a mismatch falls through to normal re-matching instead, which also makes a stale entry self-heal on the next genuine press of that chord rather than staying stuck until focus changes. - Rolling one chord into another without fully releasing the first (Cmd+N into Cmd+T at speed) evicted it from the single pending slot, so its eventual release found nothing to fire and leaked to the focused component with no matching key-down to explain it. pendingShortcut is now pendingShortcuts, keyed by physical keycode, so concurrently-held chords on different keys get independent slots. Also: a release that matched and cleared an armed chord is now always consumed, regardless of whether the underlying dispatch reports handled - the key-down was already withheld from the focused component, so letting the key-up through alone on a refused gate or a plugin that unregistered mid-chord left a listener a key-up with no matching key-down to make sense of. tabCycleActive/tabCycleModifierKeyCode are now internal (from private) so a test can pin the arm-at-press behavior directly. KeyboardShortcutInterceptor.kt (still unwired to any production caller) gets the same stale-arm protection via onFocusChanged, for the "consistent key-up semantics" criterion the next person wiring it up will actually see. New/updated tests: armTabCycleIfApplicable's MRU-gating in isolation (arms for TAB_NEXT/TAB_PREVIOUS under MRU, does nothing otherwise or for an unrelated action), a structural proof that handleKeyReleased no longer arms the cycle itself, a stale-arm-with-different-modifiers regression, a two-independent-chords regression, and the gate-race test updated to assert consumption instead of the old handled-based return. 16/16 ShortcutKeyUpInvocationTest, all touched suites green. * fix(shortcuts): finish reviewed cancellation and headless regressions * refactor(shortcuts): resolve CI complexity findings * test(crash): tolerate transient Windows report read locks * test(crash): wait for drain marker deletion on Windows --------- Co-authored-by: Antriksh Co-authored-by: Shivang * fix(db): stop materializing inherited client routine access * fix(logging): mask a URL whose fragment comes before its query (#541) Preserve masked URLs when a fragment precedes the query. Original implementation and four regression tests by @arjun28115. Reviewed current-head Claude feedback and passing CI; existing adjacent nested-query behavior remains a documented follow-up. * test(config): isolate Chromium flags test from local environment variables (#507) * test(config): isolate Chromium flags test from local environment variables (cherry picked from commit c6bce0ded1a4157b84cb724577f89ff9a0cd3cc9) * test(config): pin the env-wins and blank-UNSET rules the envReader seam unlocks Addresses the Claude review of #507: - move the envReader seam declaration up beside settingsFile so the KDoc reattaches to envOverride and the seam is initialised before bootSettings = loadSync() (object initialiser declaration order); - previewValue test now exercises the environment outranking the setting and the blank-reads-as-UNSET guard, both untestable before the seam existed; - reword two comments that the seam made stale. * test(config): close out the second Claude review of #507 Addresses the re-review of 6bcd83c73 (comment text only, plus one hermeticity line in a test): - the init-order comment read inverted - it called an initialiser below the envReader declaration the hazard, when the hazard is an initialiser ABOVE it (which is exactly what the old layout had, with bootSettings declared above the seam); reworded; - 'envOverride reads the environment only' now stubs envReader to null for its own assertions, so it is hermetic by construction rather than by the BOSS_TEST_UNUSED_KEY naming convention. --------- Co-authored-by: Shivang * fix(search): route cross-window tab selections to target window (#500) * fix(search): route cross-window tab selections to target window * refactor(search): focus target window via WindowFocusManager, wire TabEventBus to kernel IPC * fix(search): honor closed target window, forward origin window in TabEventBus Addresses the Claude review of the cross-window tab selection change: - TabEventBus.selectTab now takes sourceWindowId (the dialog's own window) and forwards it as the IpcEventBridge envelope source, matching the origin contract the other twelve buses and EventBusServiceImpl rely on. - TabSelectEvent is @Serializable so kernel-mode forwarding emits structured JSON instead of the toString() fallback with a WARN. - onTabSelect honors WindowFocusManager.focusWindow's Boolean result: a closed target window now logs a warning instead of emitting into the void (the bus has no replay). - The 50 ms effect delay is named TAB_SELECT_FOCUS_GRACE_MS with a comment stating it is UX grace, not a correctness barrier. - TabSelectEventTest: the no-replay test proves live delivery first and only then asserts the late collector sees nothing; both tests use explicit subscription barriers instead of yield(); ipcBridge is reset in @AfterTest; KDoc states the tests pin the bus contract, not the shipped BossAppEventBusEffects filter. * docs(search): correct bus KDocs after destination-addressed TabEventBus Per Claude delta review at af7c292: - BossAppEventBusEffects file KDoc: note the TabEventBus exception to the window-filtered-by-sourceWindowId rule (Issues #498/#506). - TabEventBus KDoc: spell out the cross-process subscription consequence (subscribers must not set SubscribeRequest.sourceWindowId to their own window id; route on the payload's targetWindowId) and fix the 'this file's twelve sibling buses' wording. - TabSelectEventTest KDoc: the bus carries two properties, not three. --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux (#535) * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux - Problem: MenuShortcutBridge.getKeyShortcut() always set meta=true for Cmd bindings, which Compose Desktop maps to the Super/Windows key on Windows/Linux, so native menu items like Ctrl+N/Ctrl+, failed to fire. - Fix: resolve effectiveCtrl and effectiveMeta from SystemUtils.isMacOS before constructing KeyShortcut, matching the platform-aware mapping in KeymapMatcher and AWTKeyboardInterceptor. - Verification: ./gradlew :composeApp:desktopTest --tests "ai.rever.boss.keymap.ShortcutCaptureConsumersTest"; :composeApp:ktlintCheck; :composeApp:detekt. * fix(keymap): gate browser zoom/reload menu items, make mapping testable per-platform Addresses the Claude review of the menu accelerator change: - The View menu's Actual Size / Zoom In / Zoom Out / Reload items now carry enabled = hasBrowser like Back/Forward/Developer Tools. Their bindings are BROWSER-context and AWTKeyboardInterceptor declines them outside a browser tab, so before this the newly-live Ctrl accelerators on Windows/Linux could fire them from a terminal or editor tab. - The platform modifier mapping is extracted into the pure menuAcceleratorModifiers(hasCmd, hasCtrl, isMacOS), with the asymmetry comment moved with it. The test now pins both platform branches unconditionally on any runner; the bridge-level tests keep asserting the live SystemUtils wiring. - One-line comment keeping the legacy packed-keycode coverage attached to Key.N.keyCode.toString(). * docs(keymap): record the deliberate menu-gate asymmetry in three stale comments Per Claude delta review at 928d3b206: after gating the four View items with enabled = hasBrowser, three comments still described the pre-gate behaviour. - windowsWithActiveBrowser KDoc now enumerates all seven gated items. - activeBrowserWindows KDoc records that the gate is deliberately stricter than the dispatch target (sidebar-slot / background-split browsers lose Zoom and Reload from the View menu; the broader gate would swallow Ctrl+R and Cmd+[/Cmd+]). - The 'gate is stricter' test restates the asymmetry without the now-false claim that a sidebar browser still gets Zoom and Reload. --------- Co-authored-by: kshivang Co-authored-by: Shivang * fix: keep focus mode rail actions stable (#519) Keep focus-mode quick actions stationary in the collapsed rail while the drawer reveals beside it. Preserve rail interaction when the chevron dismissal catcher is present and keep heavyweight tooltips visible. Original implementation: Ayush Santosh Chhatre (@Ayush04-C). Consolidation source #534: Rohan Gaikwad (@rohangaikwad4), credited for the parallel stationary-rail implementation and permanent-activity-bar framing. Prior review-agent catcher and regression-test repairs remain separately attributed in the PR description and review record. Reviewed head 9e939539c8f971cd45e33aa336d261eb21d5a492: all nine CI checks pass, including three platform builds/tests and code quality. Claude's final import/KDoc findings were addressed; dev does not alter the PR's touched files. Manual visual checks remain recorded in the PR/review inventory. * fix(passkey): escape mobile WebAuthn HTML templates against reflected XSS (#546) Escape mobile WebAuthn template values in their HTML and inline JavaScript contexts to prevent reflected script injection. Original vulnerability analysis, contextual escapers and initial regression tests: Antriksh (@Antriksh1984). Prior agent follow-ups repair replacement-pattern expansion and add single-pass substitution plus additional regression coverage. Agent repairs are separate from the original submission score. Validation: all nine current-head CI checks pass; prior combined current-dev integration passed 167 Deno tests and 784 pgTAP assertions. Claude findings have been addressed with dispositions documented on PR #546. No production deployment is included. Co-authored-by: Antriksh * fix(project): stop the per-window recent-projects provider from leaking, without freezing KERNEL clients (#524) Stop window-owned recent-project collectors at disposal while keeping KERNEL watch streams connected to process-wide state. Original implementation by Antriksh; prior review integration and regression hardening are documented in the PR. Co-authored-by: Antriksh * Stop FileIcons keeping a second copy of the icon extension list (#525) * Stop FileIcons keeping a second copy of the icon extension list `FileIcons.forFile` gated `LanguageIcons.forExtension` behind a `when` whose 52 branches were byte-identical: each listed some extensions and then called `LanguageIcons.forExtension(extension).toFileIconInfo()`. The branches carried no behaviour, only a vocabulary, and that vocabulary is already declared in LanguageIcons' own `when` in the adjacent file. It is one of the duplicated language tables BossConsole#75 is about, and #358 did not reach it: that PR consolidated the host and out-of-process language-ID tables, while the icon layer kept its own. The copy had already drifted, which is the failure mode #75 describes. Eleven extensions LanguageIcons has icons for were missing from the gate and fell through to the generic file icon: astro, eslintignore, eslintrc, gql, graphql, pom, prettierignore, prettierrc, prisma, proto and styl. Nothing reported that; the file just rendered as a blank document while the icon sat one file away. Measured before changing anything: the gate routed 108 extensions and LanguageIcons knows all 108, so no extension loses an icon. The gate was a strict subset, so deleting it is additive. `forExtensionOrNull` is the nullable variant that lets a caller ask whether an extension is recognised without comparing against `unknown`, which a real mapping could legitimately return. `forExtension` keeps its total contract and delegates. The 19 explicit branches stay ahead of the delegation because they deliberately disagree with the language mapping: a `.xml` is a config document, `.bat`/`.cmd` take PowerShell's colour, `.env` is a lock, and `jar`/`war`/`ear` are archives carrying Java's colour rather than Java source. `forSpecialFileName` still runs first, which is why `pom.xml` is Maven rather than XML. FileIconsLanguageDelegationTest pins all of it: the fallback for a genuinely unknown extension, a spread of the previously gated extensions, each of the eleven that had drifted, every override that must keep winning, the jar case, and that `forExtension` is still total. 47 tests in the module, 0 failures. Module ktlint and detekt clean; the two existing detekt baseline entries were rekeyed to the renamed function rather than widened. Co-Authored-By: Claude Opus 5 (cherry picked from commit a1c298ef63aeea9c676c2f45022b87007d6258c2) * style(file-icons): fix stale section comments left by the delegation change The 'Shell scripts' comment described branches this change removed, and the empty slot under 'Databases' is where the removed sql branch sat. Comments only; no behavior change. (cherry picked from commit 86a031c6adad92f5b74b4e83dd98b142c3706880) * style(file-icons): drop the value-identical gradle branch and stale docs Addresses the Claude review of #525: - remove the explicit "gradle" branch from forFile: it was value-identical to LanguageIcons' own mapping, i.e. exactly the duplication this PR removes; gradle files now resolve through the forExtensionOrNull delegation, pinned with a non-special name (libs.gradle); - the build.gradle assertion tested forSpecialFileName, not the removed branch; the xml-vs-maven override is now pinned directly; - move the Apple property list comment back above the plist branch and drop the orphan blank line at the Windows batch comment; - delete the orphaned KDoc left by the forExtension rename; - prune the baseline entry for the png line that is a block body now. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(plugin-store): stop the update check mis-ordering versions it cannot parse (#539) * fix(plugin-store): stop the update check mis-ordering versions it cannot parse `PluginRepositoryManager.isNewerVersion` split a version on "." and dropped any segment that was not a bare integer. Dropping rather than zero-filling shifts every later segment into the wrong position, so the comparison was made against the wrong field. Two cases a user would notice: "1.0.0+build.7" parsed as [1, 7]. Compared against an installed [1, 0, 0] the 7 landed in the minor position and won, so the store offered an update to the version already installed, on every check. SemVer section 10 says build metadata is ignored for precedence. An installed "1.0-beta.5" parsed as [1, 5], so the 5 sat in the minor position and beat the 2 of a genuine "1.2.3" release. The real update was never offered at all. The codebase already has one answer for this. SemanticVersion describes itself as the sole version-comparison primitive for plugin update and floor checks, and PluginUpdateManager uses it. This was the third comparator and the only one that disagreed with it; across a small matrix of realistic version strings the two disagreed on 32 of 156 ordered pairs. plugin-dependency has no project dependencies of its own, so taking it as a dependency here cannot introduce a cycle, and plugin-updater already does it. One behaviour is preserved deliberately rather than inherited: a plugin whose installed version cannot be parsed is still offered an update. It is already in a broken state, and refusing every future update would strand it there. An unparseable candidate is never offered, because nothing can be said about it. Ten tests, of which five fail against the previous implementation. Co-Authored-By: Claude Opus 5 * docs(plugin-repository): correct version-shift comments and cross-reference the two comparators Addresses the Claude review of #539 (comments and one log line; no behaviour change): - the old code read '1.0.0+build.7' as [1, 0, 7] (the 7 in the PATCH slot, not the minor) and '2.0.0-rc.1' as [2, 0, 1] returning true on 1 > 0 at the third position; the test and KDoc comments now say so; - the test KDoc now names which cases are regression tests and which are guards - most also pass against the old comparison; - both isNewerVersion KDocs now cross-reference each other and state the deliberate fail-open vs fail-closed divergence on unparseable installed versions; - checkForUpdates documents that it has no production caller today (Toolbox path is PluginUpdateBridge -> PluginUpdateManager) and logs when a candidate is refused because its store version is not parseable, keeping 'no update offered' and 'we could not tell' distinguishable. * test(plugin-repository): close out the second Claude review of #539 Addresses the re-review of 20b7e6ff5: - the test KDoc listed six regression cases, but 'a release is newer than its own pre-release' passes against the old comparison too ([1,2,3] vs [1,2] decides at the third position both ways) - it is a guard, as the PR body already said; the list now names the five real regressions; - the checkForUpdates KDoc claimed the function is 'pinned by tests' while nothing called it; it is now driven end to end by two new tests (a newer store version is offered; an unparseable store version is never offered), which also cover the new offerUpdateIfNewer helper and its debug log; - the KDoc now states the honest reason the callerless function is kept: the repository layer should answer the same question the updater does if the paths are ever merged. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(downloads): stop a hostile file name throwing out of the download handler (#540) * fix(downloads): stop a hostile file name throwing out of the download handler FileNameSanitizer.sanitize runs on the name the download source chose, so its input is whatever a server puts in Content-Disposition. Two inputs got the wrong answer. A long extension threw. Nothing bounds how long an extension is: it is whatever follows the last dot. A name of "a." plus 300 characters gives an extension of 301, and subtracting that from the 255 limit gave a negative budget that reached take(), which rejects a negative count and throws out of the handler. The extension is now abandoned when it cannot fit, rather than shortened, because a truncated extension is not the file's type and inventing one is worse than having none. A name of only dots came back ending in a dot. For ".", ".." or "...", the extension computed in step 4 is a lone ".", and the "ensure at least one character" fallback appended it raw to produce "download." That re-created exactly the trailing dot step 5 removes, and a trailing dot is what is invalid on Windows. Truncation could re-expose one too, so the rule is re-applied to whatever the cut produces. The file had no tests. Eleven are added, four of which fail against the previous implementation. They also pin the behaviour that was already right, so the Windows device names, the traversal reduction and the control-character handling cannot regress silently. Verified against the local ktlint and detekt gates. Co-Authored-By: Claude Opus 5 * fix(downloads): abandon an oversized extension instead of truncating it Review follow-up on @arjun28115's fix: the documented intent is that an extension which cannot fit in the 255-character limit is abandoned rather than shortened (a truncated extension is not the file's type), but the budget <= 0 branch kept a prefix of the extension. Preserve the base name only, cut to the limit, and pin the behavior with a regression test. 12 tests, 0 failures; ktlint and detekt clean. * fix(downloads): recompute the cut's extension and re-defuse reserved names Review follow-up on @arjun28115's fix: - truncate() took the extension computed before the trailing-dot trim. For exactly the hostile shape this PR targets - a name ending in a dot - that copy is a stale lone '.', so a real '.pdf' that fit was dropped and the download landed extensionless. The extension is now recomputed from the string being cut, as the helper's KDoc always claimed. - The cut's trim can also shrink the base onto a reserved device name step 4 missed: 'CON ' (space before the dot) is not a reserved segment, so with an oversized extension the result was bare 'CON', which Windows refuses. The cut result is now defused like step 4. - The device-name test title overclaimed ('whatever their case or extension'); it now names what it covers, pins the defused shape, and includes the trailing-space input the description claimed was tested. 14 tests, 0 failures; ktlint and detekt clean. * fix(downloads): compare a device name at the first dot, as Win32 does The review of 9d04f35 is right and the disposition that closed finding 2 was not. A device name followed by an extension is still the device: Microsoft's naming rules say NUL.txt and NUL.tar.gz are both equivalent to NUL, and this file already agreed, since step 4 rewrites CON.txt to _CON.txt. The check compared the segment before the LAST dot, which leaves two holes that are the same underlying fact. Multi-dot names were missed entirely. "CON.tar.gz" was compared as "CON.tar", which is not reserved, so it passed through untouched. Truncation could manufacture a device out of a name that was not one. "CONSOLE." followed by 251 characters is not reserved at step 4. Cutting it to fit the limit left "CON." and the rest, which Windows resolves to the console, so the download writes nowhere and no file appears. The defuse added in 9d04f35 did not catch it, because it compared the whole cut rather than the base, and only the budget <= 0 branch could ever match. Both close by comparing the segment before the first dot, in one helper used by step 4 and by the cut. Trailing spaces are trimmed before the comparison for the same reason: Windows ignores them. Defusing prepends one character, which would push a full-length result to 256. The cut is therefore redone one character shorter when that happens, which is always enough because the prefix is exactly one character. Three tests, all of which fail against 9d04f35. The last asserts the invariant rather than a case: no input in the list comes back as something Windows would resolve to a device. The title of the existing device test said "with a single extension", which was accurate before this and is not now, so it has been corrected rather than left to mislead. 17 tests, ktlint and detekt clean. Co-Authored-By: Claude Opus 5 * test(downloads): pin the retry-that-defuses cut and the CONSOLE redo shape Closes the two test-only items from the third Claude review of 40700c137: - new test for the one path where a defused cut survives into the returned value: the 255 cut and the 254 redo both trim onto CON, both defuse, and the result lands exactly at the 255 limit; - the CONSOLE. + 251x test now pins the full redo shape (CO. + 251x) instead of a non-device check that would also accept a fallback name; - the padded-name test comment now says step 4, not the cut, defuses it; - truncate's comment states the cutTo limit + 1 bound that makes one redo always enough, and the step 4 extension local says it serves the step 6 fallback. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(browser): prevent memory leak by unifying browser teardown paths (#502) Unify external browser closure with idempotent full teardown. Includes reviewed EDT dispatch, unconditional unregister and regression guards. Original contribution: Anshul Pundhir (@pundhiranshul). Review-agent hardening is documented in PR #502. Co-authored-by: pundhiranshul * fix(runner): honor main-panel rerun delay and document unsupported settings (#486) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and name… * fix(repository): a cancelled store call is not a network failure (#531) * fix(repository): a cancelled store call is not a network failure; apply downloadPlugin's rule to every store call * test(repository): pin propagation and the ratePlugin path; fix stale call-site comments Claude review follow-up on #531, on top of aryanbajaj868's fix: - RemotePluginRepositoryCancellationTest: ratePlugin is driven by the held server too (it refuses before a request without an access token; a.b.c is a structurally valid three-part token with no admin claim), and every store call is now asserted to propagate the caller's CancellationException out of the method, not merely to stay out of the log - a swallowed cancellation is also no ERROR line, so the log assertion alone cannot see that half. - onStoreFailure moved into the Helper Functions section; the test server's single dispatch thread is documented. - Three call-site comments (StoreMissingDependencyInstaller, PluginStoreVersionBridge, PluginLoadGateRecovery) restated their rationale: the bundled repository now rethrows a caller's cancellation, but the PluginRepository interface permits either shape, so the flattened handling and the rethrow guards stay. --------- Co-authored-by: Shivang * Restrict completed_authentications to the service-role Edge Function (#536) * Restrict completed_authentications to the service-role Edge Function completed_authentications holds the freshly minted access_token and refresh_token for the cross-device (QR) login handoff. Its anon policies were each guarded only by `session_id IS NOT NULL`, and session_id is NOT NULL on every real row, so all three were `USING (true)` written the long way. Nothing required the caller to know a session id, so the "unguessable UUID acts as a bearer token" model the surrounding comments describe was never enforced. With `GRANT ALL ON TABLE ... TO anon` and an anon key that is public by construction (it ships in the desktop app and this repo is open source), that was reachable by anyone who can reach PostgREST: 1. Token harvest. Selecting the table returns every in-flight login's access and refresh token. Polling faster than the legitimate desktop wins the race to any QR-logging-in user's session, which is account takeover. 2. Denial of service. A single DELETE wipes all in-flight logins. 3. Session fixation. anon INSERT pre-seeds a victim's session_id with attacker-controlled tokens, which the victim's desktop imports as its own. Reported as BossConsole#528 by @Rushikeshiitb, including the fix shape used here. Safe because no client reaches the table directly any more, which I verified rather than took on trust: grepping the desktop source for completed_authentications returns nothing outside build output, and the desktop polls GET /passkey/auth/status/{sessionId} instead (SupabaseApiClient.kt:103). Every read and write happens inside the passkey Edge Function, whose Supabase client is built with SUPABASE_SERVICE_ROLE_KEY (passkey/index.ts:36). service_role bypasses RLS, so the anon and authenticated policies and grants are dead code on the live path. The authenticated policies go with the anon ones because the flow does not use them either, and leaving a role with standing access to a table of bearer tokens is the condition that made this exploitable. Follows 20260910120000_restrict_plugin_downloads_rls.sql: revoke the base-table privileges as well so the gate does not rest on RLS alone, including from PUBLIC, since a grant there would survive revoking the two named roles. The six policy names were checked against 20251023000013_rls_policies.sql programmatically. A mistyped name in DROP POLICY IF EXISTS is a silent no-op that would leave the hole open, and the pgTAP test asserts absence rather than trusting the drop. Co-Authored-By: Claude Opus 5 * test(db): carry over service_role privilege assertions from #530 BossConsole#530 by @Rushikeshiitb asserted that service_role keeps every verb on completed_authentications after the lockdown; this suite only checked the surviving policy. Merge the two companion submissions by adopting those four assertions (plan 11 -> 15) so a too-aggressive revoke of the service-role grant fails the pgTAP suite. * fix(db): revoke client EXECUTE on the expired-row cleanup RPC cleanup_expired_completed_authentications() is granted to anon and authenticated at 20251023000014_grants.sql:240-241, but no client code calls it: it returns void, only deletes rows past expires_at_timestamp, and after the table revoke it cannot execute its DELETE as a client anyway. Revoke the client EXECUTE so no client role keeps any handle to the token table, and pin it with three pgTAP assertions (plan 15 -> 18). Answering the open question raised in the PR description. * fix(db): also revoke PUBLIC EXECUTE on the cleanup RPC PostgreSQL grants EXECUTE on functions to PUBLIC by default, so the previous REVOKE ... FROM anon, authenticated left =X/postgres in the function ACL and has_function_privilege kept returning true for both client roles (pgTAP tests 16-17 failed on the previous head). Revoke from PUBLIC as well, matching the table revoke; service_role keeps its explicit grant (verified f|f|t in a scratch PostgreSQL 17). * test(db): pin the policy set and the verbs a named REVOKE leaves behind Claude's review of this PR found the two per-role policy checks would pass a policy created without a TO clause (pg_policies records it as roles = {public}, so it applies to everyone yet matches neither client role), that TRUNCATE - the one verb row level security cannot stop - was unasserted, and that authenticated was missing its UPDATE assertion. - Assert the full policy set is exactly the service_role one instead of checking each client role separately; verified on scratch PostgreSQL 17 that an unscoped policy fails the new assertion and passes the old one. - Assert TRUNCATE, REFERENCES and TRIGGER for both client roles, matching the precedent suite plugin_downloads_access_test.sql. - Add the authenticated UPDATE assertion, so both client roles are now covered per verb as the header promised. - Reword the cleanup-RPC comment to lead with the REVOKE EXECUTE as the actual gate and flag the stale SECURITY DEFINER header comment on cleanup_expired_completed_authentications (20251023000003_passkey_functions.sql:221) that made the fallback argument fragile. plan 18 -> 24. Verified all 24 assertions on a scratch PostgreSQL 17 replica of the post-migration state. --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang Co-authored-by: Shivang * fix(security): stop two audit logs accepting forged rows from any client (#538) * fix(security): stop two audit logs accepting forged rows from any client `secret_access_log` and `plugin_api_key_logs` are the audit trails for secret access and for plugin-store API key usage. Both had an INSERT policy of `WITH CHECK (true)` with no `TO` clause, and a policy with no `TO` clause applies to PUBLIC, which includes anon and authenticated. Both tables are writable by those roles. secret_access_log has an explicit `GRANT ALL ... TO anon, authenticated`. plugin_api_key_logs has no explicit grant, but 20251023000014_grants.sql sets `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon, authenticated` and that table is created later, so it inherits it. The anon key ships in the desktop client, so anybody could append arbitrary rows to either log. secret_access_log.user_id was unconstrained, so a row claiming another user viewed or shared a secret could be written at will, and the table's own SELECT policy then shows it to the named user and to every admin as though it were real. A genuine access event could equally be buried under forged entries, which is the failure an audit log exists to prevent. Neither policy comment described this. The plugin_api_key_logs policy is named "Service role can insert API key logs", which is what it was meant to be and not what it did. No legitimate writer is affected. Every function that writes secret_access_log inserts auth.uid() as user_id, which the new predicate permits. plugin_api_key_logs is written only by log_api_key_action(), which is SECURITY DEFINER and granted to service_role alone, so it bypasses RLS and needs no INSERT policy at all. Nineteen pgTAP assertions cover the fix, with privileges checked per verb rather than with a single ALL check, because has_table_privilege(..., 'ALL') is true only when every verb is held and one re-granted verb would pass an ALL-shaped assertion while leaving the hole open. Co-Authored-By: Claude Opus 5 * fix(db): close the definer-RPC door Claude's review opened Claude review found the plugin_api_key_logs half did not close the hole: log_api_key_action() is SECURITY DEFINER owned by postgres, and clients still held EXECUTE on it (PostgreSQL grants EXECUTE to PUBLIC by default, plus the schema-wide default-privilege grants inherited by this later-created function; the explicit service_role grant is additive). Any client could forge API-key history through the RPC even with the table locked. - Revoke EXECUTE on log_api_key_action() from PUBLIC, anon and authenticated; service_role (the only caller, via the plugin-store Edge Function's service-role client) keeps it. - secret_access_log: revoke authenticated INSERT too - every writer is SECURITY DEFINER owned by postgres and bypasses RLS without client table privilege, so a scoped client INSERT could only forge self-attributed rows. Drop the INSERT policy instead of recreating it scoped, since no client statement can insert anyway. - Tests: assert the RPC EXECUTE rejections/retention (3), flip the authenticated INSERT assertion, replace the three policy-shape assertions with an absence check (plan 19 -> 20). * fix(security): close the second RPC and the verbs a named REVOKE leaves behind Builds on kshivang's 2328a13, which closed the definer-RPC door the review found. Three things the review named are still open after it. update_api_key_last_used() has the same shape as log_api_key_action(): same file, SECURITY DEFINER, EXECUTE never revoked, service_role grant additive. A client can still stamp last_used_at on any key id, which corrupts the same audit story from the other end and makes a dormant key look live. Revoked, with the same three assertions the other RPC now has. Revoking named verbs leaves the rest of the original GRANT ALL: TRUNCATE, REFERENCES and TRIGGER. TRUNCATE is the one that matters, because it is not subject to row level security at all, so no policy can be its backstop. anon already lost these to the REVOKE ALL; authenticated did not. The per-verb argument in the test header was applied to one table. For plugin_api_key_logs only anon INSERT, authenticated INSERT and authenticated SELECT were asserted, so a re-grant of anon SELECT, UPDATE or DELETE, or of authenticated UPDATE or DELETE, failed nothing. That is the precise failure the header set out to prevent. The API key log policy's command is pinned too: a policy recreated as FOR ALL under the same name and role would otherwise pass every other assertion while granting far more. Six assertions are behavioural rather than catalogue-shaped, following plugin_downloads_access_test.sql: set the role, attempt the write, assert 42501. Each is one of the three defects in the migration header, tested directly rather than inferred from a privilege bit. Plan 20 to 39. I still cannot run supabase test db locally: no Supabase CLI, psql or Docker on this machine. CI's Database job remains the real check. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang * refactor(startup): split monolithic main.kt into modular startup phases (#450) * fix(microkernel): resolve startup failures and stabilize service lifecycle * fix: CI workflows * refactor(startup): split monolithic main.kt into modular startup phases * fix: CI and workflows * fix(startup): preserve failure notices and safe descendant teardown * test(startup): pin forwarding and shutdown extraction contracts * fix(kernel): report a restart cap once per failed generation * style(startup): format extraction regression tests * fix(startup): keep restored comments scoped to window composition * fix(startup): separate cancellation from shutdown timeout * fix(startup): acquire instance ownership before starting kernel * fix(startup): release ownership on construction failure and retry partial natives * fix(microkernel): retain repair notices and await forced process exit * test(startup): simplify interrupted native stream fixture * fix(startup): publish extracted native files atomically * fix(startup): coalesce retained diagnostics and normalize saved mode writes * fix(startup): unify effective mode gates and bound operator diagnostics * fix(settings): retain pending process mode and report save failures * fix(settings): synchronize pending mode across windows and explain unavailable builds * fix(settings): let shared preference refresh own displayed mode * fix(settings): use themed save errors and strengthen availability coverage * fix(build): restore store synchronization imports after dev integration * fix(settings): show running readiness and load mode support asynchronously * fix: failed menu save in BossWindow * fix(startup): integrate current dev, restore merge-dropped startup wiring The prior dev merge reintroduced closed #419 content on top of current dev, leaving duplicate single-instance blocks, a parallel BOSS_MODE preference writer, and dropped shutdown-registration wiring. - Remove the duplicate single-instance block in main.kt; keep the CliBootstrap.forwardToExistingInstance path - Restore the Phase 5 shutdown-hook registration (ShutdownSequence.register { kernelBootstrap } + lock-acquired log) that the merge dropped - Drop the branch's parallel EnvVars/ConfigLoader mode writers in favor of dev's canonical MicrokernelModePreference (#481); AdvancedSettings is byte-identical to dev - Route CLI deep-link forwarding through dev's forwardDeepLinkWithRetry (action links: 1 attempt; others: 3) without short-circuiting a multi-file selection after one failure - Restore awaitForcedExit waits + takeUnless{isAlive} unregister guards in OutOfProcessPluginSpawnerImpl cleanup/terminate (load-bearing for reaping; dev keeps the window-scoped hashed processId) - Remove dead microkernelMenuLabel; update CliBootstrap/EnvVars tests and AGENTS.md BOSS_MODE section to match dev's model * fix(startup): close the env_vars reader split and review follow-ups Env vars: - One shared normalization (EnvVarsFormat, commonMain) for the env_vars file: both in-repo readers (ConfigLoader's BOSS_MODE tier and MicrokernelModePreference's toggle reader) now parse identical line semantics, so Settings and the runtime gate can no longer state opposite modes for one file. export KEY=value is now recognized by both (shell convention; env_vars doubles as the secret-manager plugin's key file), and an indented '# BOSS_MODE=KERNEL' is a comment in both readers. - ConfigLoader.loadEnvVars widens its catch to Throwable: an ExceptionInInitializerError or NoClassDefFoundError from BossDirectories' own static init can no longer escape ConfigLoader's and poison every later getConfig call. - Restored dev's per-attempt 'Open request forwarding completed' INFO log in CliBootstrap (the extraction had left only the failure WARN). - A fully successful KERNEL spawn is logged, not toasted: only missing JARs or failed spawns report to the startup notice queue (previously every healthy launch showed a 12s developer-worded toast). Docs: - AGENTS.md: the env_vars priority paragraph and the microkernel toggle section no longer contradict each other - the toggle now activates the next launch via the env_vars tier, applied on restart, with the latched startup snapshot still as the 'restart required' comparand. - startupEnabledLatched KDoc updated to the post-env-vars-tier reality. Tests: - EnvVarsTest: export-prefixed and indented-comment line pins. - MicrokernelModePreferenceTest: indented-comment reads as disabled; new test asserts the settings reader and ConfigLoader reader agree line by line across the sample matrix. - ServiceStartupSummaryTest: the notice gate (no toast on a clean spawn, toast on missing/failed services). --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges (#505) * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges BossConsole#53 built a ServerInterceptor (ProcessIdentityInterceptor / ProcessTokenRegistry) that verifies which process is calling the kernel's out-of-process IPC surface, independent of anything a request body claims - but only wired it into PluginUIServiceBridge. The other fourteen service bridges, documented in that interceptor's own KDoc as "none of them authenticated today", still trust every caller unconditionally. SecretServiceBridge and RoleManagementServiceBridge are the two where that actually matters: the whole password vault (list/search/create/update/ delete/share/unshare) and RBAC (roles, permissions, grants) had no caller attribution at all. Any out-of-process plugin that can reach the kernel's local IPC socket could read, mutate or share every secret, or mint and revoke permission grants, with nothing to tell one plugin's calls from another's. Both bridges now require AUTHENTICATED_PROCESS_ID on every RPC - reads included, not only mutations, since an unattributed read of the vault list is exactly the exposure this closes - and refuse with PERMISSION_DENIED before the request ever reaches the underlying provider when there is none. Every mutating call also logs the verified caller identity, so a compromised or rogue plugin's activity is attributable after the fact. Every legitimate out-of-process plugin already carries a valid credential in production (ChildProcessBootstrap attaches ProcessTokenClientInterceptor whenever BOSS_PROCESS_TOKEN is set, which ProcessSpawner sets for every child it spawns), so this closes the gap without changing behavior for any real plugin. Tests exercise both bridges over a real gRPC server and real client, mirroring PluginUIServiceBridgeTest's own harness: an authenticated caller succeeds, an anonymous one is refused, and - the assertion that actually matters - the underlying provider is never invoked on a refusal. * test(kernel): pin the identity gate on late-registered bridges and close the updateSecret gap - SecretServiceBridgeTest: updateSecret gets its own anonymous-refusal test, matching the vault mutations it already covered. - SecretServiceBridgeTest: a service registered on an already-started server (BossIpcServer's late-service fallback registry, as KernelBootstrap.registerPluginServices does) is still gated by ProcessIdentityInterceptor - anonymous calls are refused before the vault is reached, token calls go through. This pins the registration order (fallbackHandlerRegistry before intercept) the production path relies on. - KDoc on both authenticatedCallerOrRefuse helpers: the AUTHENTICATED_PROCESS_ID snapshot is correct for unary RPCs; a streaming bridge must read CURRENT_IDENTITY instead (see PluginUIServiceBridge.streamUI). * test(kernel): tighten RBAC/secret refusal assertions, add revoked-token coverage, fix stale KDocs RoleManagementServiceBridgeTest: getRolePermissions, validateRoleName and validatePermissionName were asserted only as "some StatusException", with no counters on FakeRoleManagementProvider at all - a bare assertFailsWith would pass just as happily on UNAVAILABLE or UNIMPLEMENTED, contradicting the class's own KDoc claim that every refusal test proves the provider was never reached. Added the three missing counters and now assert PERMISSION_DENIED plus a zero counter on every refusal in both bridge test files, not just the two "an anonymous caller is refused" tests. SecretServiceBridgeTest: the late-registration test called tokenRegistry.issue(DEFAULT_PROCESS) for the same id setUp already issued for - issue() replaces and invalidates the previous token by design, so this silently killed the credential the shared `authenticated` stub carries for the rest of the class. Now issues a distinct id. lateServer teardown moved into a finally so a failed assertion doesn't leak a bound server. Moved an orphaned KDoc comment back onto FakeSecretDataProvider (SingleServiceRegistry's insertion had left it dangling above the wrong class). Added a revoked-token test to both files: identityFor returns null for a token the registry no longer holds, the post-reapChildren/post-respawn case, previously untested - every existing refusal test only covered "no credential at all", not "a credential that was live and got pulled". Two stale KDoc facts fixed in boss-ipc: ProcessIdentityInterceptor's comment named PluginUIServiceBridge as the only bridge checking identity (now three of fifteen, with SecretServiceBridge and RoleManagementServiceBridge named); BossIpcServer's tokenRegistry KDoc claimed a null registry "leaves every service's behaviour exactly as it was", which stopped being true for a server hosting either of the two fail-closed bridges - a null registry there means every call refused, not merely unauthenticated as before. * docs(ipc): correct the unauthenticated bridge count in the interceptor KDoc The kernel registers 16 bridges on its ipcServer (15 in KernelBootstrap.registerPluginServices plus PluginUIServiceBridge at KernelBootstrap.kt:475); three authenticate, so thirteen are unauthenticated, not twelve. Per the Claude review's one-word count fix. --------- Co-authored-by: kshivang * refactor(plugin-launchpad): address review feedback on permissions, rollback, and staging safety - Set default template requiredPermissions to emptyList() for open access - Loosen validator to accept valid RBAC permission strings and handle emptyList() - Retain staged dev artifacts on HostOffline instead of blindly pruning - Implement symmetric multi-window rollback differentiating hot-reload from first-link - Add BufferUnderflowException and IndexOutOfBoundsException safety in bytecode fallback parser - Add unresolvable synthetic class fixture in closed-archive test to exercise bytecode fallback - Deeply validate startup dev JARs (ZIP validity, manifest existence, pluginId match) before swapping - Gate external Gradle test execution behind boss.test.e2e.gradle system property - Disallow '.' in SingleInstanceManager plugin dev identifier validation - Add DevPluginRollbackTest suite covering multi-window rollback and staging safety * test(launchpad): add hot-reload rollback regression and enforce permissions field precision - Add hot-reload failure rollback test in DevPluginRollbackTest restoring prior jar and enabled state - Enforce requiredPermissions emission in PluginScaffolder with explicitNulls = false in launchpadJson - Pass all ktlint and detekt linter checks with zero violations - Verify headless launcher test and benchmark test suite * test(db): gate the two client-grant shapes that produced #547 three times (#596) * test(db): gate the two client-grant shapes that produced #547 three times `ALTER DEFAULT PRIVILEGES` in 20251023000014_grants.sql hands every later table client grants, and three tables then shipped with a permissive write policy on top of that: plugin_downloads (#488), completed_authentications (#536) and secret_access_log / plugin_api_key_logs (#538). Each was caught by a person reading a migration. identity-disclosure audit. That audit asks who can READ identity. Neither it nor the default-privileges change reaches the shape above: a default-privilege revoke is not retroactive, CHECK 3 filters `cmd in ('SELECT','ALL')` so an INSERT policy is invisible to it, and CHECK 3b filters `not relrowsecurity` while both #538 tables had RLS enabled with a permissive policy. Adds supabase/audit/client_grant_audit.sql with two exact catalog checks: * G1 names any public table a client role can reach without RLS. No allowlist, because all 34 tables in the schema enable RLS today. * G2 names any INSERT/UPDATE/DELETE/ALL policy reachable by anon, authenticated or PUBLIC whose predicate is the literal `true`. G2 keys on the predicate, not on a missing `TO` clause. The missing `TO` is how all three slipped past review, but 101 of the 128 policies in this schema omit it, so gating on that would report most of the schema. Predicate plus write plus client role matches two policies in the whole schema, and both are the ones #538 removes. G2 is asserted as a subset rather than as HEALTHY while those two are still on dev, so the gate passes before and after #538 and still fails on anything new. Co-Authored-By: Claude Opus 5 * fix(db): exclude restrictive policies from write lint * docs(db): state audit limits and cross-suite regression coverage * docs(db): name the exact companion policy assertion --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(browser): actually warn before downloading an executable file (#484) * fix(browser): actually warn before downloading an executable file warnForExecutables defaults to true and its KDoc promises a warning before a recognized executable finishes downloading, but the branch that was supposed to show it was an empty TODO block ("for now, just proceed") - so the download always proceeded silently regardless of the setting, for every user, with no way to turn the (nonexistent) warning off since there is no Settings UI for it either. confirmExecutableDownload mirrors pickSaveFile's own pattern exactly, since both are called from the same synchronous StartDownloadCallback and both need a blocking answer before it returns: a Swing confirm dialog run via SwingUtilities.invokeAndWait, failing closed (treated as cancel) if the dialog itself cannot be shown, matching how a null save path from pickSaveFile already cancels the same callback. Declining releases the claimed save path and cancels the download the same way the existing parent-directory-failure bail does, just above it in the same callback. * fix(browser): close executable consent and cancellation gaps * fix(browser): bind download consent to a usable window and explicit choice * feat(browser): add a user-facing toggle for the executable download warning Settings > Browser > Downloads > "Warn before downloading executable files", on by default. BrowserSettings.warnForExecutables is persisted through the existing BrowserSettingsManager JSON store alongside the other browser toggles, and FluckEngine reads it live at download time instead of the unpersisted DownloadSettings copy, so a change applies to the next download without restarting BOSS. Disabling it skips only the executable warning; filename checks, save-path handling and cancellation cleanup are unchanged. Addresses the maintainer's follow-up on #484. * fix(settings): index the executable-download-warning toggle for search SettingsSearchIndexDriftTest failed CI on all three platforms: the new "Warn before downloading executable files" toggle (Settings > Browser > Downloads) had no entry in SettingsSearchIndex, so Settings search could not find it. Added a Downloads group under the FLUCK section, extracted into its own downloadsEntries() helper alongside the existing fileAndLinkEntries() to keep browserEntries() under detekt's LongMethod threshold. * fix(browser): pin the warning toggle's persistence and volatility - BrowserSettingsManager: settingsFile is now an internal var with a reloadForTest() seam (same pattern as DefaultAppsSettingsManager), so the persisted-value tests exercise the real saveSettings/load path instead of re-stating the assignment; covers save+reload round-trips, a file predating the key, and a corrupt file - ExecutableDownloadConsentTest: the live-read test re-implemented the call site, so it now pins the StartDownloadCallback argument textually (SettingsSearchIndexDriftTest idiom) - a revert to the unpersisted downloadSettings copy now fails the build - BrowserSettings.warnForExecutables is @Volatile: written on the Compose UI thread, read on the JxBrowser download callback thread - DownloadSettings.warnForExecutables removed: no reader left it; the live flag is BrowserSettings.warnForExecutables * test(browser): make the persistence tests measure the reload, not the assignment Addressing the second Claude review of 70a4874: - default test asserted process-global state that the test's own @BeforeEach causes to be loaded from the developer's real settings file - it would pass in CI and fail on a machine with the toggle off. It now pins the persisted shape's default; the in-memory direction is carried by the predating-key reload test. - the round-trip test moved the in-memory value away between save and reload, so deleting the load-apply in BrowserSettingsManager now fails it. - the whole persisted shape (all eleven fields, including the four system-property mirrors) is snapshotted in @BeforeEach - after the first access that may trigger the manager's real-file load - and restored in @AfterEach, since reloadForTest() rewrites all of them. - reloadForTest KDoc states the deliberate no-reset divergence from DefaultAppsSettingsManager.resetForTest. * fix(browser): test the download dialog's owner resolution and surface it R1: the dialog-owner resolution (active window, else a fallback, else refuse) was the only new decision in the executable-download warning with no test - the branch that silently cancels a user's download had nothing pinning it. Extracted pickDialogOwner(activeWindow, candidates), mirroring resolveActionableWindowIdFrom's own pure-function split, and added cases covering active-window-wins, non-displayable-active falling back, and no-usable-owner refusing. R2: the fallback resolved exactly one window id and refused outright if it wasn't displayable, even when another registered window was open - reachable in the interval where a window has been disposed but its EDT unregister hasn't run yet. pickDialogOwner now takes an ordered list of candidates and scans all of them; WindowFocusManager. candidateWindowsForDialogOwner() supplies that list (the resolved fallback window first, then every other registered window), covered by a test where the first candidate is stale and a later one is usable. R3: nothing called toFront()/requestFocus() on the resolved owner, so a background BOSS could still show this modal somewhere the operator doesn't see, with the JxBrowser callback thread waiting behind it. Already running on the EDT inside confirmExecutableDownloadOnEdt's own Runnable, so this calls AWT directly rather than needing invokeLater. * fix(review): preserve current release metadata in download consent PR * fix(review): tighten executable consent cleanup and default coverage --------- Co-authored-by: Shivang * test: harden source convention scans (#508) Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> * fix(plugins): scope PluginStorageFactory.createStorage to the calling plugin's own id (#548) * fix(plugins): scope PluginStorageFactory.createStorage to the calling plugin's own id TrackingPluginContext (the only PluginContext a plugin's own code ever holds - every registration path wraps createSandboxedContext in one before calling register()) exposed the raw, unscoped PluginStorageFactory straight through from its delegate. That factory takes an arbitrary pluginId per call and the desktop implementation persists purely off that string (~/.boss/plugin-data/{pluginId}/storage.properties), with no check anywhere that it names the caller. Any installed plugin could reach another plugin's persisted data with nothing more than: context.pluginStorageFactory?.createStorage("victim-plugin-id") - read, overwrite, or clear() it. Added ScopedPluginStorageFactory, which binds createStorage to the context's own pluginId (a construction-time value the plugin does not control) and ignores whatever id the caller passes in - the same pattern DownloadCenterProviderImpl.forPlugin already uses to keep one plugin's transfer ids from colliding with or addressing another's. SandboxedPluginContext is untouched: it already passed downloadCenterProvider through unscoped in the same way, since TrackingPluginContext wraps it and is the only context a plugin ever sees - matching that existing, working precedent rather than adding a second, redundant scoping layer. Added tests/TrackingPluginContextStorageScopeTest.kt: the underlying factory is only ever asked for the context's own plugin id regardless of what a caller requests, and a write made through one plugin's storage reference never lands in another plugin's real storage. Co-Authored-By: Claude Sonnet 5 * test(plugins): model shared storage in isolation regression * test(plugins): assert scoped provider identity and document boundary --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(browser): validate co-browse control payloads before splicing into JS (#549) Validate remote co-browse control objects before embedding them in page JavaScript. Reject unquoted expressions recursively and preserve valid control messages and escaped string data. Original diagnosis, object guard and six regression tests by Antriksh. Prior review repair adds recursive token validation and four regressions; contributor replayed all three commits cleanly onto dev. All ten hosted checks and ten focused tests pass. Co-authored-by: Antriksh Co-authored-by: Claude Sonnet 5 * fix(kernel): require verified caller identity for the Supabase proxy and Download bridges (#550) * fix(kernel): require verified caller identity for the Supabase proxy and Download bridges SupabaseServiceBridge is a generic, unrestricted Postgrest proxy backed by the signed-in user's own RLS-scoped Supabase session - its rpc() reaches the same create_secret/update_secret RPCs SecretServiceBridge exists to protect, and select() can read any table RLS exposes. Neither select nor rpc checked caller identity (BossConsole#53), so any local process reaching the kernel's IPC socket had the same reach as a legitimately installed plugin, attributable to nobody. Both now require a verified process identity, matching SecretServiceBridge's established pattern, with audit logging on every call. DownloadServiceBridge.openFile/revealInFolder shell out to the OS's own "open with default application" command for any path that exists on disk, with no check that the path has anything to do with a download - before this, an unauthenticated caller on the IPC bridge could make BOSS execute any file on disk via the OS default-handler mechanism, completely bypassing BossConsole#484's executable-download-warning consent dialog (that path never goes through it at all). Every RPC on the bridge now requires a verified caller identity, and openFile/revealInFolder are additionally confined to a path this provider's own downloads list is currently tracking - canonical-path compared, so a symlink or a `..` cannot walk outside it - turning "open any file that exists" into "open a file BOSS itself downloaded". Both bridges get real gRPC-server-and-client tests (matching PluginUIServiceBridgeTest's pattern): an anonymous caller is refused with PERMISSION_DENIED on every RPC, the underlying provider is asserted never invoked on a refusal, and DownloadServiceBridge additionally covers an authenticated caller naming a path outside the tracked set. * fix(kernel): revalidate download streams and use checked paths * fix(kernel): capture stream identity and reject empty download paths * style(tests): align download regression expression wrapping * fix(kernel): clear code-quality on the download/supabase caller-identity branch detekt's default MaxLineLength (120) rejected a 128-char assertEquals in DownloadServiceBridgeTest that ktlint's wider 140-char .editorconfig limit had let through - wrap it. Also finishes the finding-6 KDoc reword the bot review flagged as ungrammatical ("this is the path does not provide" -> "this path provides no executable-consent step") and reflows that paragraph. No behavior change; addresses the last remaining code-quality blocker noted in the PR's review thread. --------- Co-authored-by: Shivang * fix(plugins): give a plugin's file panel an owner and its own directory mode (#551) (#552) * fix(plugins): give a plugin's file panel an owner and its own directory mode (#551) FilePickerProviderFactory created every panel as FileDialog(null as Frame?, ...) and showed it with a bare isVisible = true. Two consequences, and they are different failures. No owner. The panel has no window to be modal to and none to come forward over, so it can open behind the Compose window. onResult(null) then reaches the plugin looking exactly like a cancel. No directory mode. apple.awt.fileDialogForDirectories is process-wide and read by the native peer at creation, and a modal panel runs a nested event loop on the EDT that keeps dispatching. NativeFileDialogs already recorded this file as one of three that create a LOAD dialog without stating the flag, and the consequence it named: such a panel opened inside the browser's folder panel comes up as a directory chooser. That part is a defect independent of the reported symptom. NativeFileDialogs had already solved both for the browser's own panels, and its comment says the durable fix is to route every FileDialog in the tree through one helper. This extracts that helper rather than adding a third copy of it. NativeFileDialogs now delegates to it and loses its private duplicates, so the two cannot drift. The property discipline is split out as withDirectoryMode so it is testable without a display: an absent property is cleared rather than written back as "false", a present one is restored verbatim, nesting unwinds in reverse, and a throwing body still restores. Seven tests cover it. The owner selection and the panel itself are not covered and cannot be from a headless test. The catch blocks now log at error rather than warn and say that the plugin will see the failure as a user cancel, since onResult(null) is the only channel this API has and the log is the only place the difference exists. 570 tests in the browser package pass, including the existing NativeFileDialogs suite. ktlint and detekt clean. Co-Authored-By: Claude Opus 5 * fix(plugins): preserve save filters from PR 611 Consolidate Jaspreet Singh’s save-filter addition into arjun28115’s shared owned-dialog implementation. Remove comments left behind by the helper extraction. Co-authored-by: Jaspreet Singh * test(pickers): preserve pre-existing directory flag in teardown * fix(pickers): deliver one result per pick, and accept a dotted extension The two shared-provider defects handed back on #552. Neither came from the ownership change; both live in the picker this PR touches, and both are silent. 1. `onResult` was called inside the same `try` that catches picker failures, so a plugin whose callback threw received its path and then a `null` immediately after. `null` is indistinguishable from a cancel, so one pick produced two answers and the second one was wrong. Delivery now goes through `deliverOnce`, which sets its flag before invoking, so a throw from the plugin's callback cannot be retried by the catch. The two throws are told apart by when they happen: a picker failure arrives before delivery and still reports the cancel. 2. The API takes `List` and says nothing about the spelling, so plugins pass both `csv` and `.csv`, and every consumer appended its own dot. The macOS filter tested `name.endsWith(".$ext")` and Swing's FileNameExtensionFilter adds the dot itself, so `.csv` was matched as `..csv`. No file is named that, so the filter hid every valid choice instead of narrowing to them. `normalizedExtensions` strips a leading dot and drops blanks, and both platforms and both methods now route through it. Normalising rather than declaring a bare-extension contract, because the contract is not checkable at the plugin boundary and the failure it produces is a panel that lists nothing, which reads as "no matching files here". Both fixes are in Open and Save. The Save path also gains a comment saying what its filter does NOT do: a native SAVE panel narrows what is listed and does not constrain what the user types, so the returned path may still carry any extension or none. The three helpers are `internal` and pure so the rules are testable without an AWT panel on the EDT. The provider calls exactly these, so reverting a call site is a compile error rather than a silently unexercised test. 11 tests cover one delivery per pick, a throwing callback, a failure before delivery, csv/.csv, mixed case, whitespace, blank-only and empty filter lists. ktlint 1.8.0 clean; no line over 120 columns. No local test run is claimed. Co-Authored-By: Claude Opus 5 * fix(pickers): repair what CI caught on the rebased head Two failures, both mine, neither caused by the rebase. `compileTestKotlinDesktop` failed at FilePickerDeliveryTest:34. `assertEquals` takes one type parameter for both arguments, and `listOf("...")` against a `MutableList` gives it nothing to unify, so inference failed outright. The expectation is now spelled `listOf(...)`, matching the two assertions further down that already were. The `emptyList()` expectations are spelled `emptyList()` for the same reason, before they hit it too. `detekt` failed on SpreadOperator at both FileNameExtensionFilter constructions. The rule is not new and the code is not either: renaming `filters` to `extensions` moved the element out of whatever suppressed it before. Suppressed at the two functions with the reasoning written out, matching what NativeFileDialogs.onOpenFiles in this same PR already does. The spread is over a plugin's declared filter list, a handful of entries, and FileNameExtensionFilter is Java varargs with no collection overload to prefer. Worth saying plainly: a type error is something a compile would have caught and I shipped it without one. There is no working Gradle path on this machine, so ktlint is the only gate I can run locally, and it does not type-check. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang Co-authored-by: Jaspreet Singh * feat(mcp): trust a whole plugin's tools at once, not one at a time (#542) * feat(mcp): trust a whole plugin's tools at once, not one at a time Adds a "Trust This Plugin" option to the MCP tool approval dialog - a persisted, provider-wide ALLOW covering every tool a plugin contributes, not just the one call being approved. Answers the actual friction: a plugin with several mutating tools currently means approving each one individually, in the same session, one at a time. McpToolPolicyConfig gains a second rule map, providerRules, keyed by providerId rather than tool name. policyFor(toolName, providerId) now resolves both, with DENY - at either level - always winning first and unconditionally: a provider-wide DENY is not "most specific wins" and must not be overridable by a narrower per-tool ALLOW, the same posture DENY already has against session trust everywhere else in this engine. Below that: session trust, then an explicit tool-specific rule (ALLOW or ASK), then the provider's own ALLOW, then the risk-based default. providerId is an optional parameter so an existing caller that only ever checked a tool name keeps compiling. setToolPolicy, the new setProviderPolicy and revokeProviderPolicy now share one persistAndPublish helper for the persist-and-fault-report shape, so a future change to how a write failure is logged or reported cannot update one path and silently miss the others - the mistake a similar policy engine made once already by not sharing this logic. revokeProviderPolicy removes the key rather than rewriting it to some neutral value, for the same reason. McpApprovalDecision.Approved gains a trustProvider flag alongside the existing trustForSession one. Persisting it is a synchronous disk write, so it runs under withContext(Dispatchers.IO) rather than on whatever dispatcher delivered the MCP call - and a failed persist doesn't block the call the operator already approved past; it falls back to session trust for that one tool instead; a new PROVIDER_TRUST_PERSIST_FAILED ledger disposition records that the durable grant did not save. The new button sits in its own row below the main actions, not next to "Approve Once" - it is the broadest grant this dialog can make, so it should take a deliberate reach rather than risk a fast click landing on it by mistake. A caption names the scope. Revocation is a new McpProviderTrustDialog (list + per-row revoke, mirroring the shape an equivalent per-tool dialog would need) reachable from a "Trusted plugins (n)" button in the bottom bar, next to the existing session- trust revoke button - only shown once a provider is actually trusted. Test plan: McpProviderPolicyTest pins policyFor's full precedence table (provider ALLOW/DENY against tool rules, session trust, the policy-unreadable fault) and persistence (round-trips through a real file; revoke removes the key, survives reload; revoking one provider leaves another's trust untouched). McpGovernedInvocationTest adds two integration cases through the real registry: approving one tool with trustProvider lets a sibling tool from the same provider through with no second prompt and no session-trust call, and revoking provider trust makes its tools ask again. No Compose-level test for the dialogs themselves - no test infrastructure exists yet for this UI layer on this branch (no ScrimmedModalSemanticsTest-style harness), noted here rather than left unstated. Verified: full :composeApp:allTests passes except the two pre-existing, environment-specific Windows symlink-privilege failures already known from earlier this week (DesktopFileScannerTest, DiscardDownloadContainmentTest). ktlintCheck/detekt clean. * test(mcp): pin the provider-trust persist-failure fallback A failed provider-wide persist must still run the already-approved call, record PROVIDER_TRUST_PERSIST_FAILED, session-trust only that one tool, and leave the provider's other tools asking. Mirrors the blocked-write setup the per-tool failure case in McpPersistentApprovalTest uses. Agent-side regression coverage added during sweep BOSS_SWEEP_20260911_mcp-policy; not part of the author's submission. * fix(mcp): guard provider-wide trust writes against a stale approval dialog trustProvider ("Trust This Plugin") called setProviderPolicy directly, with none of the revocation/DENY/availability guards validateApproval already applies before setToolPolicy's persisted write. A revoke or DENY landing while the dialog was open (e.g. an operator resetting the tool from "Persisted MCP policies") could be silently overwritten: the call in hand was correctly withheld, but the provider-wide ALLOW still persisted, leaving every other tool that provider contributes AUTO_ALLOWED from an authorization the reset was supposed to invalidate. setProviderPolicy now takes the same preserveDeny/expectedRevocation guards setToolPolicy has, checked under the same lock, plus an optional toolName since the revocation check is inherently per-tool. The trustProvider branch in approvedAuthorization pre-checks isAvailable/revocationVersion/policyFor before writing (mirroring validateApproval exactly) and, on a false return, disambiguates a guard refusal (POLICY_DENIED, call does not run) from a genuine disk failure (PROVIDER_TRUST_PERSIST_FAILED, call still runs - the existing, deliberate fallback for that case). New regression test in McpGovernedInvocationTest reproduces the review's exact scenario (a revokePersistedPolicy landing while a prompt is open) and confirms it fails without the fix. AGENTS.md's governed-MCP section now documents provider-wide trust as its own scope, including the deliberate asymmetry between a stale-dialog refusal and a disk-failure fallback. * fix(mcp): invalidate queued grants when provider trust is reset * fix(mcp): clear code-quality on the provider-scoped-policy branch detekt flagged setProviderPolicy's preserveDeny guard as ComplexCondition (4 boolean/comparison operators inline) and a 121-char assertEquals in McpProviderRevocationTest as MaxLineLength (over detekt's default 120, though under ktlint's wider 140-char .editorconfig limit). Extract the DENY check into isProviderOrToolDenied() - same behavior, named and testable on its own - and wrap the long assertEquals. No behavior change. --------- Co-authored-by: Shivang * fix(keymap): make an explicit Ctrl chord fire on the Control key off macOS (#553) (#559) Make explicit Ctrl bindings fire on Control off macOS, with shared capture/matching rules and platform-aware chord identity for conflict detection and migration. Preserve distinct macOS modifiers and reject unsupported Super capture. Original implementation and contributor follow-up by @arjun28115. Earlier review-agent work integrated dev, rejected unsupported Super captures, and corrected comments; those repairs are separately credited in the PR description. Closes #553. * fix(chrome): restore a minimized window before focusing it (#560) (#561) Restore iconified frames before focusing through either WindowFocusManager entry point, preserving maximized state. Original shared helper and headless mask tests by @arjun28115 (#561). Independent equivalent implementation and missing-target regression by @Sahvendra7 (#580), retained through consolidation. Earlier review-agent work integrated that regression and clarified restore ordering and documentation. Closes #560. Co-authored-by: Sahvendra * fix(cli): prevent readiness queue lost commands (#633) * fix(cli): prevent readiness queue lost commands * fix(cli): close initialization queue race and guard deferred logging --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(cli): queue external command approval prompts * fix(plugin-launchpad): harden dev jar deduplication, rollback state matrix, and archive parsing * docs(launchpad): align plugin.json schema, requiredPermissions, and pre-load startup docs * style: fix ktlint import ordering in BossBottomBar.kt * fix(launchpad): address round 5 maintainer feedback for multi-window rollback and startup fallback - DevPluginReloader: track actually-modified managers during unload/install, guard untouched or unload-refusing windows from mistaken force-uninstall in rollback, collect per-window recovery errors, and ensure clean classloader state before reinstalling prior JARs. - PluginStoreSetup: add startup fallback to store build if staged dev JAR fails registration or loading in loadPersistedPlugins. - DefaultPlugin: filter out dev JARs claiming system/protected IDs before grouping in deduplicateJars to drop lone dev JARs claiming protected IDs. - Tests: drive DevPluginRollbackTest through public DevPluginReloader.reload, verify multi-window partial unload refusal rollback isolation, add startup fallback test, and add protected dev JAR deduplication test. * fix(detekt): resolve code quality violations in PluginStoreSetup and DevPluginRollbackTest * fix(launchpad): bound manifest decompression stream and expand IPC response buffer - Expand SingleInstanceManager.MAX_RESPONSE_BYTES to 1024 bytes and clamp reload error to 400 chars, avoiding buffer overflow and false timeouts on diagnostic failures. - Introduce DevPluginArtifacts.MAX_MANIFEST_BYTES (512 KB Int) and DevPluginArtifacts.readBoundedUtf8String to bound manifest decompression streams against zip-bombs and OOM. - Guard PluginValidator.readArchive with failing manifest-valid check and return nullable PluginManifest? from readManifestFromJar without throwing IllegalStateException on overflow. - Add regression tests in SingleInstanceChannelTest and PluginValidatorEvalTest for 350+ char error payloads and oversized manifest validation failure. * fix(launchpad): bound manifest decompression in DefaultPlugin and fallback on dev registration failure * fix(launchpad): fallback to standard build on external dev plugin failure * fix(launchpad): address maintainer review for staging root, system deduplication, reload mutex, and retention * refactor(launchpad): refine system plugin authoritative check, chaining in main, and session retention helper * Respect horizontal scrollers and commit trackpad navigation on release * Fix swipe cancellation, observer lifecycle and terminal delivery * Publish prior contact termination for home swipe attribution * fix(kernel): require verified caller identity for the run-configuration bridge (#634) * fix(overlays): honor RTL and fractional placement (#477) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot sw… * fix(secrets): make the secret listing RPCs fail soft on one bad row (#668) * fix(secrets): make the secret listing RPCs fail soft on one bad row BossConsole#620: get_user_secrets, search_user_secrets and get_user_secrets_with_shared each decrypt password_encrypted and recovery_codes_encrypted with a bare call to decrypt_text inside a set-returning query. decrypt_text raises on an undecryptable value - a wrong key after a partial key rotation, storage corruption, or an HMAC/padding mismatch - and one raised row aborts the whole query, so the caller sees every one of their secrets fail to load rather than just the bad one. twofa_secret already reads through safe_decrypt_twofa_secret (NULL on failure). recovery_codes_encrypted already had a safe wrapper too - safe_decrypt_recovery_codes, whose own doc comment says "used internally by get_user_secrets functions" - but whichever later migration touched these RPCs stopped calling it and fell back to raw decrypt_text, so it sat unused. password_encrypted never had a safe wrapper at all. Adds try_decrypt_text(text), mirroring safe_decrypt_recovery_codes's BEGIN/EXCEPTION WHEN OTHERS THEN RETURN NULL shape for a scalar result, locked down the same way safe_decrypt_twofa_secret is (no client role gets EXECUTE). Routes the password read in all three RPCs through it and the recovery-codes read back through the existing safe wrapper. One corrupt row now surfaces as a blanked field - password NULL, recovery_codes [] - while every other secret in the page still loads. Deliberately not named safe_decrypt_*: ops/rotate_master_encryption_key.sql's re-encrypt loop treats every function matching that name pattern as a mapped bespoke-format adapter and refuses to run if one is unmapped. try_decrypt_text wraps the same plain envelope that script already re-encrypts directly, so it must stay outside that sweep. Scoped to the three user-facing listing RPCs only; the operator-run backfill/rotation paths keep using raw decrypt_text and should still fail closed. * Fix installed-client compatibility and preserve systemic decryption failures Includes desktop regression coverage from #621 by Rushikeshiitb and review repairs by kshivang; related independent #652 work by akashjadon04 informed the review. --------- Co-authored-by: Shivang * fix(kernel): require verified caller identity for the project-data bridge (#666) * fix(kernel): require verified caller identity for the project-data bridge BossConsole#53: ProjectDataServiceBridge 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 watch every recently opened project's path (which routinely contains a username) with no credential, and could force an arbitrary window to switch its open project via selectProject. That is not only a UI surprise: a project switch publishes a ProjectChangeEvent on the application-wide event bus every installed plugin can subscribe to, so an unauthenticated caller could trigger that broadcast on demand. Applies the same fail-closed pattern already used for ActiveTabs, Download, Git, Log, PluginUI, RoleManagement, RunConfig and Supabase: every unary RPC requires AUTHENTICATED_PROCESS_ID, the watch stream captures CURRENT_IDENTITY before collecting and re-checks it on every emission so a revoked token stops a live stream, and every mutation gets an attributed audit log line. SplitView remains the one unguarded service on this server. Rewrote ProjectDataServiceBridgeTest against a real gRPC server (the existing tests called the bridge directly, which the new gating would have refused outright) - refusal-with-no-credential, authenticated mutation, revoked unary and revoked stream cases, plus the two existing routing guarantees now exercised over an authenticated channel: selectProject's UI-dispatcher confinement and watchRecentProjects reading the process-wide ProjectState singleton rather than a per-window mirror. Also folded the stale, repeatedly-drifting enumeration of which bridges fail closed out of BossIpcServer's own KDoc - it named only two of what are now nine - in favor of pointing at each bridge's own KDoc instead. * fix(review): clarify project identity guarantees and harden test lifecycle * style(test): keep filtered flow chain continuation together --------- Co-authored-by: Shivang * fix(git): make clone timeout cancellable (#660) Fixes #641. Consolidates the independently implemented clone-timeout work from #659 and #660. Abhiram Nair authored the original onExit lifecycle, deterministic process tests, real process-tree coverage and public clone cleanup/retry repairs. Antriksh independently authored the interruptible-wait alternative and real-Git acceptance tests; target preservation, ordinary failure and final-completion coverage are incorporated with explicit credit. Maintainer repairs serialize interruptible progress delivery, make callback failures advisory and preserve successful exit during delayed notification. Co-authored-by: passionate-coder26 Co-authored-by: Antriksh * fix(kernel): require verified caller identity for the split-view bridge (#672) * 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. * test(kernel): verify split-view identity coverage and clarify scope --------- Co-authored-by: Shivang * feat(mcp): let operators set tool policy proactively (#636) * feat(search): index open tabs by their URL and file path (#606) (#648) Global Search matched an open tab only against its title, and TabResult hardcoded url = null / filePath = null on every hit. A browser tab whose title never mentions its domain, or an editor tab someone wants to find by path rather than filename, could not be found at all. searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and an editor tab's EditorTabInfo.filePath, through the same substring- containment gate (proseScore) searchRecentPages already uses for a page's URL, since a fuzzy subsequence match over a whole URL or path is close to meaningless. TabResult carries the real url/filePath instead of null, and the tab row's subtitle now leads with the workspace name (disambiguating identical URLs open in different workspaces, or across windows) followed by the url/filePath/tabType, in place of the old "tabType • workspaceName" line. 11 tests cover: URL-only and file-path-only matches, both fields populated for the right tab type and left null for the other/a blank path, a short query not matching a URL by fuzzy accident, title+URL both matching taking the higher (title) score with title highlight ranges preserved, live navigation changing which URL matches (and the old URL no longer matching), directory-path matching without a subsequence false-positive, and no-open-tabs contributing nothing rather than failing. * fix(browser): replace superseded capture requests (#509) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate d… * feat(mcp): add a clickable activity log for recent tool calls (#662) * feat(search): index open tabs by their URL and file path (#606) (#648) Global Search matched an open tab only against its title, and TabResult hardcoded url = null / filePath = null on every hit. A browser tab whose title never mentions its domain, or an editor tab someone wants to find by path rather than filename, could not be found at all. searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and an editor tab's EditorTabInfo.filePath, through the same substring- containment gate (proseScore) searchRecentPages already uses for a page's URL, since a fuzzy subsequence match over a whole URL or path is close to meaningless. TabResult carries the real url/filePath instead of null, and the tab row's subtitle now leads with the workspace name (disambiguating identical URLs open in different workspaces, or across windows) followed by the url/filePath/tabType, in place of the old "tabType • workspaceName" line. 11 tests cover: URL-only and file-path-only matches, both fields populated for the right tab type and left null for the other/a blank path, a short query not matching a URL by fuzzy accident, title+URL both matching taking the higher (title) score with title highlight ranges preserved, live navigation changing which URL matches (and the old URL no longer matching), directory-path matching without a subsequence false-positive, and no-open-tabs contributing nothing rather than failing. * fix(browser): replace superseded capture requests (#509) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * V… * Fix/spotlight project session isolation (#593) * fix(overlays): honor RTL and fractional placement (#477) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * … * fix(launchpad): eager artifact retention, authoritative preflight check, and access-hidden fallback * fix(cli): report unsuccessful rollback and require reload confirmation * fix(cli): protect persisted identities and leave active staging to its writer --------- Signed-off-by: dependabot[bot] Co-authored-by: Shivang Co-authored-by: rehannayeem0786 Co-authored-by: rehannayeem0786 <128595058+rehannayeem0786@users.noreply.github.com> Co-authored-by: Antriksh1984 Co-authored-by: Shikhar Goel Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shwetas Dhake Co-authored-by: AdityaK-iiita Co-authored-by: Oleksii (Lexi) Kyrylchuk Co-authored-by: Sahvendra Co-authored-by: Aishwary Anand <201525941+Aishwary11-11@users.noreply.github.com> Co-authored-by: Aishwary Anand Co-authored-by: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Co-authored-by: ayush Co-authored-by: Aditya Mahajan Co-authored-by: Anshul Pundhir <129892939+pundhiranshul@users.noreply.github.com> Co-authored-by: Sanjay Co-authored-by: Abhiram Nair Co-authored-by: Nilesh Vasita Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: arjun28115 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Co-authored-by: Achintya Chaurasia Co-authored-by: surfthewave-commits Co-authored-by: Ayushman <139611211+InfoSage05@users.noreply.github.com> Co-authored-by: Ishaan Chowdhury Co-authored-by: Karan Raj Co-authored-by: john_cybersage Co-authored-by: aryanbajaj868 <117334799+aryanbajaj868@users.noreply.github.com> Co-authored-by: Shivanshu Co-authored-by: Patel Karma Rohit Co-authored-by: Risa Labs Co-authored-by: john k Co-authored-by: kshivang Co-authored-by: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Co-authored-by: pundhiranshul Co-authored-by: Jaspreet Singh --- .gitignore | 3 + AGENTS.md | 22 +- README.md | 7 +- composeApp/build.gradle.kts | 34 +- .../boss/components/plugin/DefaultPlugin.kt | 270 ++++- .../components/plugin/DynamicPluginManager.kt | 4 +- .../plugin/launchpad/DevPluginArtifacts.kt | 259 +++++ .../kotlin/ai/rever/boss/cli/BossCommand.kt | 15 + .../ai/rever/boss/cli/BossPluginCommand.kt | 389 +++++++ .../desktopMain/kotlin/ai/rever/boss/main.kt | 13 + .../ai/rever/boss/plugin/PluginBuildProbe.kt | 7 +- .../ai/rever/boss/plugin/PluginStoreSetup.kt | 158 ++- .../plugin/launchpad/DevPluginReloader.kt | 323 ++++++ .../boss/plugin/launchpad/PluginManifest.kt | 209 ++++ .../boss/plugin/launchpad/PluginScaffolder.kt | 734 ++++++++++++++ .../boss/plugin/launchpad/PluginValidator.kt | 635 ++++++++++++ .../ai/rever/boss/startup/CliBootstrap.kt | 2 +- .../ai/rever/boss/utils/OsOpenArguments.kt | 2 +- .../rever/boss/utils/SingleInstanceManager.kt | 172 +++- .../resources/launcher/gradle-wrapper.jar | Bin 0 -> 47505 bytes .../boss/cli/plugin/PluginEndToEndEvalTest.kt | 427 ++++++++ .../cli/plugin/PluginScaffolderEvalTest.kt | 263 +++++ .../cli/plugin/PluginValidatorEvalTest.kt | 523 ++++++++++ .../plugin/launchpad/DevPluginReloaderTest.kt | 193 ++++ .../plugin/launchpad/DevPluginRollbackTest.kt | 947 ++++++++++++++++++ .../plugin/launchpad/PluginManifestTest.kt | 432 ++++++++ .../rever/boss/utils/DevReloadResponseTest.kt | 70 ++ .../boss/utils/SingleInstanceChannelTest.kt | 31 + docs/CLI.md | 30 +- docs/PLUGIN_LAUNCHPAD.md | 201 ++++ scripts/boss | 83 +- scripts/boss.bat | 33 +- scripts/boss.ps1 | 37 +- scripts/test/test-headless-cli.sh | 0 34 files changed, 6398 insertions(+), 130 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginArtifacts.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossPluginCommand.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloader.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginManifest.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginScaffolder.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginValidator.kt create mode 100644 composeApp/src/desktopMain/resources/launcher/gradle-wrapper.jar create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginEndToEndEvalTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginScaffolderEvalTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginValidatorEvalTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloaderTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginRollbackTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/PluginManifestTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/DevReloadResponseTest.kt create mode 100644 docs/PLUGIN_LAUNCHPAD.md mode change 100644 => 100755 scripts/test/test-headless-cli.sh diff --git a/.gitignore b/.gitignore index 7eaedcd545..4eea5f6a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ captures *.zip *.msi *.jar +!gradle/wrapper/gradle-wrapper.jar +!composeApp/src/desktopMain/resources/launcher/gradle-wrapper.jar + distribution-final/ distribution/ BOSS-Standalone-Windows/ diff --git a/AGENTS.md b/AGENTS.md index dbfdc1013e..21fa8049c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2038,9 +2038,6 @@ workspace by selecting the tools you need." Tools install app-wide, not into a S - [Role Creation](docs/ROLE_CREATION_GUIDE.md) - Creating and managing roles - [Windows Deep Link](docs/WINDOWS_DEEP_LINK_SETUP.md) - Windows protocol handler setup - [Release Rebuild](docs/RELEASE_REBUILD_GUIDE.md) - Re-running release builds - - - ### Governed MCP invocation (#371) The host policy applies to registry invocation; it does not isolate installed JVM @@ -2074,6 +2071,7 @@ Provider trust also covers tools added by later versions and replacement plugins that provider id. Already queued sibling prompts still ask. Explicit tool ASK rules still override provider ALLOW. The Trusted plugins UI lists ALLOW rules only; hand-edited provider DENY rules currently require policy-file editing to remove. + Preserve a backup before manual recovery of a damaged policy; the fault flow withholds all tools until recovery. No automatic quarantine UI is provided. Ledger redaction is bounded and best effort, not a guarantee for secrets @@ -2159,3 +2157,21 @@ snapshots. Current saved rules (including ASK/default) are visible in expanded rows, and the summary distinguishes rules being replaced from new denials. Global None is a distinct deny-all preset, not Custom; Edit is the label at both levels. Search by plugin display name also matches its saved tool rules. + +### Plugin Dev Staging & Launchpad Invariants + +- **Protected Plugins Overrule Dev JARs Unconditionally**: + When deduplicating or resolving dev vs. standard plugins, `isSystemPlugin` and `requiresRestartInsteadOfHotReload` plugins MUST NEVER be superseded by a dev JAR, regardless of file modification timestamps (`lastModified`). Never rely solely on additive bonuses (`versionBonus + lastModified`) without penalizing or filtering dev JARs on protected identities. + +- **Reload Forward & Rollback State Completeness**: + Hot-reload must support both active (`LOADED`) and inactive (`DISABLED`) plugins symmetrically: + - If a plugin was disabled prior to reload (`wasEnabled = false`), forward reload must install with `enabled = false` and accept `state == DISABLED` as an expected successful outcome. + - If the active user lacks RBAC permissions, forward reload must accept `state == DISABLED && !canAccess(manifest)` as a valid outcome. + - Rollback must restore `wasEnabled = false` and reinstall `v1.jar` in `DISABLED` state without uninstallation. + +- **Archive Traversal & Stream Bounds**: + Never trust `ZipEntry.size` alone for decompression limits, as `size == -1` in streaming ZIPs. Always enforce hard byte caps on the incoming `InputStream` (e.g. `readNBytes(MAX + 1)` or explicit counter bounds) to prevent heap exhaustion. + +- **Test Veracity Rules**: + - In deduplication tests, ALWAYS test with dev JAR `lastModified` strictly greater than standard JAR `lastModified` to mirror real-world compiler outputs. + - Test the public reload pipeline (`DevPluginReloader.reload`) end-to-end rather than calling internal rollback helpers in isolation. diff --git a/README.md b/README.md index 45fafbf4f7..36cdc4f0cd 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Absolute scores were depressed by heavy co-tenancy - **800–1300% ambient CPU** - [Case study: the DNA Origami Inventor](#case-study-the-dna-origami-inventor) - [Design System](#design-system) - [Development](#development) -- [CLI](#cli) · [CI/CD](#cicd) · [Documentation](#documentation) · [Open source & ecosystem](#open-source--ecosystem) +- [CLI & Agent Harness](#cli--agent-harness) · [CI/CD](#cicd) · [Documentation](#documentation) · [Open source & ecosystem](#open-source--ecosystem) --- @@ -410,9 +410,13 @@ boss status --json # Query running application status boss mcp list --filter browser # Discover registered MCP tools boss mcp describe # Fetch tool schema on demand boss mcp invoke --args ... # Execute tool headlessly via authenticated IPC +boss plugin init # Scaffold a new plugin Gradle project +boss plugin validate # Validate plugin manifest and bytecode +boss plugin link # Stage plugin into BOSS_HOME with hot-reload ``` See [docs/CLI.md](docs/CLI.md) for full options, exit codes, stream guarantees, and agent integration recipes. +See [docs/PLUGIN_LAUNCHPAD.md](docs/PLUGIN_LAUNCHPAD.md) for plugin authoring, templates, and hot-reload workflows. **Installation:** Toolbox / Tools → Install BOSS CLI (or via Homebrew / installers). @@ -421,6 +425,7 @@ See [docs/CLI.md](docs/CLI.md) for full options, exit codes, stream guarantees, ## Documentation - [Command Line Interface (CLI)](docs/CLI.md) - CLI Agent Harness, `boss status`, `boss mcp`, and agent scripting +- [Plugin Launchpad](docs/PLUGIN_LAUNCHPAD.md) - Developer CLI (`boss plugin init`, `validate`, `link`) & hot-reload - [Core Subsystems](docs/SUBSYSTEMS.md) - Auth, UI, keyboard shortcuts, threading - [Design System](docs/DESIGN_SYSTEM.md) - "Operator's Console" tokens, themes, and the live styleguide - [BossEditor Module](docs/BOSSEDITOR.md) - LSP, PSI, editor features diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index fee25d65f2..e57e330fcc 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -73,6 +73,26 @@ abstract class JxBrowserVersionValueSource : ValueSource { + interface Parameters : ValueSourceParameters { + val tomlFile: RegularFileProperty + } + + override fun obtain(): String { + val file = parameters.tomlFile.get().asFile + if (!file.exists()) { + throw GradleException("libs.versions.toml not found at ${file.absolutePath}") + } + val content = file.readText() + return Regex("""boss-plugin-api\s*=\s*"([^"]+)"""") + .find(content) + ?.groupValues + ?.get(1) + ?: throw GradleException("Could not find boss-plugin-api version in libs.versions.toml") + } +} + // Load version from properties file using configuration-cache-compatible providers val versionPropsFile = layout.projectDirectory.file("../version.properties") val versionPropsProvider = @@ -159,6 +179,13 @@ val jxBrowserVersion = project.findProperty("jxBrowserVersion")?.toString() ?: jxBrowserVersionProvider.get() +// Configuration-cache-compatible provider for Plugin API contract version from libs.versions.toml +val pluginApiVersionProvider = + providers.of(PluginApiVersionValueSource::class.java) { + parameters.tomlFile.set(libsVersionsFile) + } +val pluginApiVersion = pluginApiVersionProvider.get() + // local.properties (git-ignored) as a lazy, configuration-cache-tracked input. // Absent file → absent provider; callers must getOrElse/orNull. val localPropertiesProvider: Provider = @@ -239,9 +266,10 @@ val generateVersionConstants = val minorProvider = propsProvider.map { it.getProperty("app.version.minor", "8") } val patchProvider = propsProvider.map { it.getProperty("app.version.patch", "0") } val prereleaseProvider = propsProvider.map { it.getProperty("app.prerelease.suffix", "") } + val pluginApiProvider = pluginApiVersionProvider val jxVersionProvider = jxBrowserVersionProvider - // Track libs.versions.toml as an input for JxBrowser version + // Track libs.versions.toml as an input for JxBrowser and Plugin API versions inputs.file(libsVersionsFile) inputs.file(versionPropsFile) @@ -256,6 +284,7 @@ val generateVersionConstants = val minor = minorProvider.get() val patch = patchProvider.get() val prerelease = prereleaseProvider.get().takeIf { it.isNotBlank() } + val pluginApiVer = pluginApiProvider.get() val jxVersion = jxVersionProvider.get() // Generate PRERELEASE constant as nullable String @@ -281,6 +310,9 @@ val generateVersionConstants = | | /** JxBrowser version from gradle/libs.versions.toml */ | const val JXBROWSER_VERSION = "$jxVersion" + | + | /** Plugin API contract version from gradle/libs.versions.toml */ + | const val PLUGIN_API_VERSION = "$pluginApiVer" |} | """.trimMargin(), diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt index 1822a07d5a..8365e23a36 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt @@ -67,6 +67,7 @@ import ai.rever.boss.plugin.api.NotificationProvider import ai.rever.boss.plugin.api.PanelRegistry import ai.rever.boss.plugin.api.PluginContext import ai.rever.boss.plugin.api.PluginSandboxRef +import ai.rever.boss.plugin.api.PluginState import ai.rever.boss.plugin.api.PluginStorageFactory import ai.rever.boss.plugin.api.PluginStoreApiKeyProvider import ai.rever.boss.plugin.api.ProjectData @@ -84,6 +85,7 @@ import ai.rever.boss.plugin.api.UserManagementProvider import ai.rever.boss.plugin.api.WorkspaceDataProvider import ai.rever.boss.plugin.api.ZoomSettingsProvider import ai.rever.boss.plugin.browser.BrowserService +import ai.rever.boss.plugin.launchpad.DevPluginArtifacts import ai.rever.boss.plugin.loader.PluginLoadException import ai.rever.boss.plugin.pathutils.BossDirectories import ai.rever.boss.plugin.sandbox.PluginSandboxManager @@ -182,6 +184,77 @@ class DefaultPlugin( var loadPersistedPluginsInternal: suspend (DynamicPluginManager) -> Unit = { _ -> // Default no-op - platform-specific code should set this } + + /** + * Authoritative check for protected system plugin IDs when no manager has loaded them yet. + * Platform-specific desktop initialization sets this to SystemPluginManifestService / PluginStoreSetup. + */ + @Volatile + var isAuthoritativeSystemPlugin: (String) -> Boolean = { pluginId -> + pluginId == MicrokernelRuntime.PLUGIN_ID || + pluginId.startsWith("ai.rever.boss.system.") || + pluginId == "ai.rever.boss.plugin.api" + } + + internal fun findActiveDevJars(devDir: File): List = + DevPluginArtifacts + .findAllActiveDevJars(devDir, deepValidate = true) + + internal fun extractPluginId(jarFile: File): String = + readManifestIdFromJar(jarFile) + ?: jarFile.nameWithoutExtension + + private fun readManifestIdFromJar(jarFile: File): String? = + if (!jarFile.exists() || jarFile.length() == 0L) { + null + } else { + readManifestText(jarFile)?.let(DevPluginArtifacts::extractPluginIdFromManifestText) + } + + private fun readManifestText(jarFile: File): String? = + try { + java.util.jar.JarFile(jarFile).use { jar -> + readEntryText(jar) + } + } catch (_: Exception) { + null + } + + private fun readEntryText(jar: java.util.jar.JarFile): String? { + val entry = + jar.getJarEntry("META-INF/boss-plugin/plugin.json") + ?: jar.getJarEntry("plugin.json") + ?: return null + return jar.getInputStream(entry).use { stream -> + DevPluginArtifacts.readBoundedUtf8String(stream) + } + } + + internal fun deduplicateJars( + jars: List, + isProtectedPredicate: (String) -> Boolean = { false }, + ): List = + jars + .filterNot { file -> + DevPluginArtifacts.isDevPluginJar(file) && + isProtectedPredicate(extractPluginId(file)) + }.groupBy { extractPluginId(it) } + .mapValues { (pluginId, group) -> + group.maxByOrNull { file -> + val isDev = + DevPluginArtifacts + .isDevPluginJar(file) + val isProtected = isProtectedPredicate(pluginId) + val versionBonus = + when { + isProtected && isDev -> -10_000_000_000_000L + isDev -> 10_000_000_000_000L + else -> 0L + } + versionBonus + file.lastModified() + } ?: group.first() + }.values + .toList() } private val logger = BossLogger.forComponent("DefaultPlugin") @@ -1180,13 +1253,21 @@ class DefaultPlugin( // updater can replace jars while startup is in flight — a listing // captured at init would try already-deleted files and never see // freshly downloaded ones. - val jarFiles = + val standardJars = pluginDir.listFiles { file -> file.isFile && file.extension == "jar" && // Skip microkernel runtime — it's a classpath dependency for OOP plugins, not a loadable plugin !file.name.startsWith(MicrokernelRuntime.ARTIFACT_PREFIX) } ?: emptyArray() + val devJars = findActiveDevJars(DevPluginArtifacts.stagingRoot()) + val jarFiles = + deduplicateJars(standardJars.toList() + devJars) { pluginId -> + manager.isSystemPlugin(pluginId) || + isAuthoritativeSystemPlugin(pluginId) || + HotReloadPolicy.requiresRestartInsteadOfHotReload(pluginId) + } + if (jarFiles.isEmpty()) { logger.debug( LogCategory.SYSTEM, @@ -1218,64 +1299,149 @@ class DefaultPlugin( .map { it.jarPath } .toSet() - for (jarFile in jarFiles) { - if (jarFile.absolutePath in trackedJarPaths) continue - try { - logger.info( - LogCategory.SYSTEM, - "Installing external plugin", - mapOf( - "file" to jarFile.name, - ), - ) - - val result = manager.installPlugin(jarFile.absolutePath) + val standardJarsById = standardJars.associateBy { extractPluginId(it) } - if (result.isSuccess) { - val info = result.getOrThrow() - logger.info( - LogCategory.SYSTEM, - "External plugin loaded successfully", - mapOf( - "pluginId" to info.manifest.pluginId, - "version" to info.manifest.version, - "displayName" to info.manifest.displayName, - ), - ) - } else if (result.exceptionOrNull()?.message?.startsWith(PluginLoadException.ALREADY_LOADED_PREFIX) == true) { - // A second jar for a plugin that's already running — a - // stale old version left in the directory, not a failure. - logger.info( - LogCategory.SYSTEM, - "Skipping duplicate jar for already-loaded plugin", - mapOf( - "file" to jarFile.name, - ), - ) + for (jarFile in jarFiles) { + val fallbackJar = + if (DevPluginArtifacts.isDevPluginJar(jarFile)) { + standardJarsById[extractPluginId(jarFile)]?.takeIf { it != jarFile } } else { - logger.error( - LogCategory.SYSTEM, - "Failed to load external plugin", - mapOf( - "file" to jarFile.name, - "error" to (result.exceptionOrNull()?.message ?: "unknown"), - ), - ) + null } - } catch (e: Exception) { - logger.error( - LogCategory.SYSTEM, - "Exception loading external plugin", - mapOf( - "file" to jarFile.name, - ), - e, - ) - } + installSingleExternalPlugin(manager, jarFile, trackedJarPaths, fallbackJar) } } } + internal suspend fun installSingleExternalPlugin( + manager: DynamicPluginManager, + jarFile: File, + trackedJarPaths: Set, + fallbackStandardJar: File? = null, + ) { + if (jarFile.absolutePath in trackedJarPaths) return + try { + logger.info( + LogCategory.SYSTEM, + "Installing external plugin", + mapOf("file" to jarFile.name), + ) + + val result = manager.installPlugin(jarFile.absolutePath) + handleExternalPluginInstallResult(manager, jarFile, result, fallbackStandardJar) + } catch (e: Exception) { + logger.error( + LogCategory.SYSTEM, + "Exception loading external plugin", + mapOf("file" to jarFile.name), + e, + ) + } + } + + private suspend fun handleExternalPluginInstallResult( + manager: DynamicPluginManager, + jarFile: File, + result: Result, + fallbackStandardJar: File?, + ) { + val loadedInfo = result.getOrNull() + val isBrokenDev = + DevPluginArtifacts.isDevPluginJar(jarFile) && + loadedInfo?.state == PluginState.DISABLED && + manager.canAccess(loadedInfo.manifest) + val isAlreadyLoaded = + result.exceptionOrNull()?.message?.startsWith(PluginLoadException.ALREADY_LOADED_PREFIX) == true + + when { + result.isSuccess && !isBrokenDev -> { + val info = result.getOrThrow() + logger.info( + LogCategory.SYSTEM, + "External plugin loaded successfully", + mapOf( + "pluginId" to info.manifest.pluginId, + "version" to info.manifest.version, + "displayName" to info.manifest.displayName, + ), + ) + } + + isAlreadyLoaded -> { + logger.info( + LogCategory.SYSTEM, + "Skipping duplicate jar for already-loaded plugin", + mapOf("file" to jarFile.name), + ) + } + + attemptFallbackOnDevFailure(manager, jarFile, fallbackStandardJar, result) -> { + // Fallback attempt was executed and logged + } + + else -> { + logger.error( + LogCategory.SYSTEM, + "Failed to load external plugin", + mapOf( + "file" to jarFile.name, + "error" to (result.exceptionOrNull()?.message ?: "Dev plugin loaded in DISABLED state"), + ), + ) + } + } + } + + private suspend fun attemptFallbackOnDevFailure( + manager: DynamicPluginManager, + jarFile: File, + fallbackStandardJar: File?, + devResult: Result, + ): Boolean = + if (!DevPluginArtifacts.isDevPluginJar(jarFile) || + fallbackStandardJar == null || + !fallbackStandardJar.exists() + ) { + false + } else { + val devError = + devResult.exceptionOrNull()?.message + ?: devResult.getOrNull()?.errorMessage + ?: "Dev plugin loaded in DISABLED state" + logger.warn( + LogCategory.SYSTEM, + "External dev plugin failed to load; falling back to standard build", + mapOf( + "devJar" to jarFile.name, + "fallback" to fallbackStandardJar.name, + "error" to devError, + ), + ) + val fallbackResult = manager.installPlugin(fallbackStandardJar.absolutePath) + if (fallbackResult.isSuccess) { + val info = fallbackResult.getOrThrow() + logger.info( + LogCategory.SYSTEM, + "External standard plugin loaded successfully as fallback", + mapOf( + "pluginId" to info.manifest.pluginId, + "version" to info.manifest.version, + "displayName" to info.manifest.displayName, + ), + ) + } else { + logger.error( + LogCategory.SYSTEM, + "Fallback to standard build also failed for external plugin", + mapOf( + "file" to fallbackStandardJar.name, + "error" to (fallbackResult.exceptionOrNull()?.message ?: "unknown"), + ), + ) + } + true + } + // ============================================================ // REMOVED BUNDLED PLUGINS // The following registration methods have been removed as these diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DynamicPluginManager.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DynamicPluginManager.kt index 47349d99c3..531c100bc5 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DynamicPluginManager.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DynamicPluginManager.kt @@ -363,7 +363,7 @@ class DynamicPluginManager( */ private val swapScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private fun activeManagers(): List { + internal fun activeManagers(): List { liveManagers.removeIf { it.get() == null } return liveManagers.mapNotNull { it.get() } } @@ -803,7 +803,7 @@ class DynamicPluginManager( * plugin's [PluginManifest.requiredPermissions]. An empty list (legacy * plugins) means "available to any authenticated user". */ - private fun canAccess(manifest: PluginManifest): Boolean = + internal fun canAccess(manifest: PluginManifest): Boolean = pluginAccessAllowed( isAdmin = _isAdmin.value, userPermissions = _userPermissions.value, diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginArtifacts.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginArtifacts.kt new file mode 100644 index 0000000000..d48fe33c11 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginArtifacts.kt @@ -0,0 +1,259 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.plugin.pathutils.BossDirectories +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.nio.file.Path + +/** + * Manages staging directories, version history, and dev JAR discovery for BossConsole plugins. + */ +@Suppress("ReturnCount", "TooGenericExceptionCaught", "TooManyFunctions") +object DevPluginArtifacts { + private val logger = BossLogger.forComponent("DevPluginArtifacts") + private val manifestJson = Json { ignoreUnknownKeys = true } + + const val DEFAULT_MAX_VERSIONS_TO_KEEP: Int = 3 + const val MAX_MANIFEST_BYTES: Int = 512 * 1024 + + /** Staging root directory override for isolated unit and integration tests. */ + @Volatile + internal var stagingRootOverride: File? = null + + /** + * Reads up to [maxBytes] from [stream] as UTF-8. + * Returns null if the stream exceeds [maxBytes] or cannot be read. + */ + internal fun readBoundedUtf8String( + stream: java.io.InputStream, + maxBytes: Int = MAX_MANIFEST_BYTES, + ): String? = + try { + stream.use { s -> + val bytes = s.readNBytes(maxBytes + 1) + if (bytes.size > maxBytes) null else bytes.toString(Charsets.UTF_8) + } + } catch (_: Exception) { + null + } + + /** + * Resolves the root staging directory for dev plugins (~/.boss/plugins/dev or ~/.boss_debug/plugins/dev). + */ + fun stagingRoot(): File = stagingRootOverride ?: BossDirectories.resolve("plugins/dev") + + /** + * Extracts the top-level plugin ID from a plugin.json manifest text. + * Accurately inspects root keys ("pluginId", "id") and ignores nested objects + * like dependencies or MCP tools that may declare an "id" field. + */ + fun extractPluginIdFromManifestText(text: String): String? = + runCatching { + val root = manifestJson.parseToJsonElement(text).jsonObject + root["pluginId"]?.jsonPrimitive?.contentOrNull + ?: root["id"]?.jsonPrimitive?.contentOrNull + }.getOrNull() + + /** + * Resolves whether [jarPath] points to a version-rotated dev JAR under the staging root. + */ + fun isDevPluginJar(jarFile: File): Boolean = isDevPluginPath(jarFile.toPath()) + + /** + * Resolves whether [jarPath] points to a version-rotated dev JAR under the staging root. + */ + fun isDevPluginPath( + jarPath: Path, + devRoot: File = stagingRoot(), + ): Boolean { + val stagingDir = devRoot.toPath().toAbsolutePath().normalize() + val candidate = jarPath.toAbsolutePath().normalize() + val parent = candidate.parent ?: return false + return candidate.startsWith(stagingDir) && parent.fileName?.toString()?.startsWith("v") == true + } + + /** + * Deeply validates a candidate dev plugin JAR to ensure: + * 1. It is a readable ZIP/JAR archive. + * 2. It contains META-INF/boss-plugin/plugin.json. + * 3. (Optional) The manifest's pluginId matches [expectedPluginId]. + */ + fun isValidDevJar( + jarFile: File, + expectedPluginId: String? = null, + ): Boolean { + if (!isEligibleJarFile(jarFile)) return false + val manifestText = readManifestText(jarFile) ?: return false + if (expectedPluginId == null) return true + return extractPluginIdFromManifestText(manifestText) == expectedPluginId + } + + private fun isEligibleJarFile(file: File): Boolean { + if (!file.isFile || file.length() <= 0) return false + return file.extension == "jar" && !file.name.endsWith(".part") + } + + private fun readManifestText(jarFile: File): String? = + try { + java.util.jar.JarFile(jarFile).use { jar -> + val entry = jar.getJarEntry("META-INF/boss-plugin/plugin.json") ?: return null + jar.getInputStream(entry).use { readBoundedUtf8String(it) } + } + } catch (_: Exception) { + null + } + + /** + * Finds the active dev JAR for [pluginId] by inspecting version directories in descending order. + * Skips empty or corrupt version directories and returns the latest directory containing a valid JAR. + * When [deepValidate] is true, candidates must also pass [isValidDevJar]. + */ + fun findActiveDevJar( + pluginId: String, + devRoot: File = stagingRoot(), + deepValidate: Boolean = false, + ): File? { + val pluginDir = File(devRoot, pluginId) + if (!pluginDir.exists() || !pluginDir.isDirectory) return null + + val versionDirs = + pluginDir.listFiles { file -> file.isDirectory && file.name.startsWith("v") } + ?: return null + + val sortedVersionDirs = + versionDirs.sortedWith( + compareByDescending { dir -> + dir.name.removePrefix("v").toLongOrNull() ?: dir.lastModified() + }, + ) + + for (vDir in sortedVersionDirs) { + val candidate = + vDir + .listFiles { file -> + file.isFile && file.extension == "jar" && !file.name.endsWith(".part") && file.length() > 0 + }?.firstOrNull() + if (candidate != null) { + if (!deepValidate || isValidDevJar(candidate, pluginId)) { + return candidate + } + } + } + return null + } + + /** + * Resolves the staging directory for a specific [pluginId] under [devRoot]. + */ + fun pluginDevDir( + pluginId: String, + devRoot: File = stagingRoot(), + ): File = File(devRoot, pluginId) + + /** + * Discovers all active dev plugin JARs under [devRoot]. + * When [deepValidate] is true, only JARs passing [isValidDevJar] are returned. + */ + fun findAllActiveDevJars( + devRoot: File = stagingRoot(), + deepValidate: Boolean = false, + ): List { + if (!devRoot.exists() || !devRoot.isDirectory) return emptyList() + + val activeJars = mutableListOf() + val pluginDirs = devRoot.listFiles { file -> file.isDirectory } ?: emptyArray() + for (pDir in pluginDirs) { + val jar = findActiveDevJar(pDir.name, devRoot, deepValidate) + if (jar != null) { + activeJars.add(jar) + } + } + return activeJars + } + + /** + * Safely prunes older version directories, retaining at most [maxVersionsToKeep] valid builds. + * Never deletes the currently active or staged version, and strictly retains any version directory + * whose JAR path is listed in [activeJarPaths] (e.g. actively referenced by any running window). + */ + fun pruneStagingHistory( + pluginDevDir: File, + maxVersionsToKeep: Int = DEFAULT_MAX_VERSIONS_TO_KEEP, + activeJarPaths: Set = emptySet(), + ) { + if (!pluginDevDir.exists() || !pluginDevDir.isDirectory) return + + val allVersionDirs = + pluginDevDir.listFiles { file -> file.isDirectory && file.name.startsWith("v") } + ?: return + + val validDirs = + allVersionDirs.filter { dir -> + val jars = + dir.listFiles { file -> + file.isFile && file.extension == "jar" && !file.name.endsWith(".part") && file.length() > 0 + } + !jars.isNullOrEmpty() + } + + val sortedDirs = + validDirs.sortedWith( + compareByDescending { dir -> + dir.name.removePrefix("v").toLongOrNull() ?: dir.lastModified() + }, + ) + + if (sortedDirs.size <= maxVersionsToKeep) return + + val normalizedActivePaths = + activeJarPaths + .mapNotNull { path -> + runCatching { File(path).canonicalPath }.getOrNull() ?: File(path).absolutePath + }.toSet() + + val candidatesToPrune = sortedDirs.drop(maxVersionsToKeep) + val dirsToPrune = + candidatesToPrune.filterNot { dir -> + isDirReferencedByActiveJars(dir, activeJarPaths, normalizedActivePaths) + } + + pruneOldVersionDirectories(dirsToPrune) + } + + private fun isDirReferencedByActiveJars( + dir: File, + activeJarPaths: Set, + normalizedActivePaths: Set, + ): Boolean { + val jars = dir.listFiles { file -> file.isFile && file.extension == "jar" } ?: return false + return jars.any { jar -> + val abs = jar.absolutePath + val canonical = runCatching { jar.canonicalPath }.getOrNull() ?: abs + abs in activeJarPaths || canonical in normalizedActivePaths + } + } + + private fun pruneOldVersionDirectories(dirsToPrune: List) { + for (dir in dirsToPrune) { + try { + dir.deleteRecursively() + logger.debug( + LogCategory.SYSTEM, + "Pruned old dev plugin staging directory", + mapOf("dir" to dir.absolutePath), + ) + } catch (e: Exception) { + logger.warn( + LogCategory.SYSTEM, + "Failed to delete old dev plugin staging directory", + mapOf("dir" to dir.absolutePath, "error" to (e.message ?: "unknown")), + ) + } + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt index af4a119781..ed8ea1d222 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt @@ -555,6 +555,16 @@ class BossCompletionCommand : CliktCommand(name = "completion") { } } +/** + * Plugin developer tools and lifecycle management. + * Subcommands (init, validate, link) provide scaffolding, validation, and hot-linking. + */ +class BossPluginCommand : CliktCommand(name = "plugin") { + override fun help(context: Context) = "Plugin developer tools and lifecycle management" + + override fun run() = Unit +} + /** * Configures Clikt command structure. */ @@ -569,6 +579,11 @@ fun createBossCLI(): BossCommand = BossDoctorCommand(), BossMcpCommand(), BossCompletionCommand(), + BossPluginCommand().subcommands( + BossPluginInitCommand(), + BossPluginValidateCommand(), + BossPluginLinkCommand(), + ), ) /** Configure before any BOSS logger initializes, keeping machine-readable stdout clean. */ diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossPluginCommand.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossPluginCommand.kt new file mode 100644 index 0000000000..943da404a4 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossPluginCommand.kt @@ -0,0 +1,389 @@ +package ai.rever.boss.cli + +import ai.rever.boss.plugin.launchpad.DevPluginArtifacts +import ai.rever.boss.plugin.launchpad.PluginManifest +import ai.rever.boss.plugin.launchpad.PluginScaffolder +import ai.rever.boss.plugin.launchpad.PluginValidator +import ai.rever.boss.plugin.launchpad.launchpadJson +import ai.rever.boss.utils.ReloadResult +import ai.rever.boss.utils.SingleInstanceManager +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.default +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.path +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption + +/** + * Scaffolds a new third-party plugin project. + * Usage: boss plugin init [--template ] [--dir ] [--force] [--json] + */ +@Suppress("TooGenericExceptionCaught") +class BossPluginInitCommand : CliktCommand(name = "init") { + override fun help(context: Context) = "Scaffolds a new plugin project" + + private val logger = BossLogger.forComponent("BossPluginInitCommand") + + val name by argument(help = "Plugin name (e.g. 'my-tools')") + val template by option( + "-t", + "--template", + help = "Plugin template: mcp-tool, ui-panel, background-service, full (default: mcp-tool)", + ).default("mcp-tool") + val dir by option("-d", "--dir", help = "Target directory path") + val force by option("-f", "--force", help = "Overwrite non-empty target directory").flag(default = false) + val json by option("--json", help = "Output machine-readable JSON").flag(default = false) + + override fun run() { + val targetDir = if (dir != null) File(dir!!).absoluteFile else File(name).absoluteFile + + try { + val result = + PluginScaffolder.scaffold( + name = name, + templateName = template, + targetDir = targetDir, + force = force, + ) + + if (json) { + val payload = + buildJsonObject { + put("status", "scaffolded") + put("pluginId", result.pluginId) + put("template", template) + put("targetDir", result.targetDirectory.absolutePath.replace('\\', '/')) + put( + "files", + buildJsonArray { + result.filesCreated.forEach { file -> + val relPath = file.relativeTo(result.targetDirectory).path.replace('\\', '/') + add(relPath) + } + }, + ) + } + echo(payload.toString()) + } else { + echo( + "[✓] Plugin '${result.pluginId}' scaffolded successfully at " + + result.targetDirectory.absolutePath, + ) + echo("Template: $template") + echo("Generated files:") + result.filesCreated.forEach { file -> + val relPath = file.relativeTo(result.targetDirectory).path.replace('\\', '/') + echo(" - $relPath") + } + } + } catch (e: Exception) { + val errorMsg = e.message ?: "Failed to scaffold plugin" + logger.error(LogCategory.SYSTEM, "Failed to scaffold plugin: $errorMsg", error = e) + if (json) { + val errPayload = + buildJsonObject { + put("status", "error") + put("error", errorMsg) + } + echo(errPayload.toString()) + } else { + echo("Error: $errorMsg", err = true) + } + throw ProgramResult(1) + } + } +} + +/** + * Validates a plugin directory or packaged archive (.jar / .zip). + * Usage: boss plugin validate [] [--json] + */ +class BossPluginValidateCommand : CliktCommand(name = "validate") { + override fun help(context: Context) = "Validates a plugin manifest and bytecode structure" + + private val logger = BossLogger.forComponent("BossPluginValidateCommand") + + val path by argument(help = "Path to plugin directory or JAR/ZIP archive") + .path(canBeDir = true, canBeFile = true) + .default(Paths.get(".")) + val json by option("--json", help = "Output machine-readable JSON").flag(default = false) + + override fun run() { + val target = path.toFile().absoluteFile + val result = PluginValidator.validate(target) + val report = PluginValidator.toReport(target.absolutePath, result) + + if (json) { + val jsonText = launchpadJson.encodeToString(report) + echo(jsonText) + if (!report.success) { + throw ProgramResult(1) + } + } else { + echo("Validating plugin at ${target.absolutePath}...") + result.checks.forEach { check -> + val mark = if (check.passed) "[✓]" else "[✗]" + echo("$mark ${check.name}: ${check.message}") + } + if (report.success) { + echo("\n[✓] Validation passed (${report.checksPassed}/${report.totalChecks} checks passed)") + } else { + logger.warn( + LogCategory.SYSTEM, + "Validation failed with ${report.failures.size} errors at ${target.absolutePath}", + ) + echo("\nValidation failed with ${report.failures.size} errors:", err = true) + report.failures.forEach { echo(" [✗] ${it.checkName}: ${it.message}", err = true) } + throw ProgramResult(1) + } + } + } +} + +/** + * Stages and links a plugin to BossConsole and dispatches a live hot-reload signal. + * Usage: boss plugin link [] [--json] + */ +@Suppress("LongMethod", "CyclomaticComplexMethod", "NestedBlockDepth", "TooGenericExceptionCaught") +class BossPluginLinkCommand : CliktCommand(name = "link") { + override fun help(context: Context) = "Links a local plugin into BossConsole development environment" + + private val logger = BossLogger.forComponent("BossPluginLinkCommand") + + val path by argument(help = "Path to plugin directory or JAR/ZIP archive") + .path(canBeDir = true, canBeFile = true) + .default(Paths.get(".")) + val json by option("--json", help = "Output machine-readable JSON").flag(default = false) + + override fun run() { + val inputPath = path.toAbsolutePath() + + if (!Files.exists(inputPath)) { + val msg = "Target path does not exist: $inputPath" + logger.error(LogCategory.SYSTEM, msg) + if (json) { + echo( + buildJsonObject { + put("status", "error") + put("error", msg) + }.toString(), + ) + } else { + echo("Error: $msg", err = true) + } + throw ProgramResult(1) + } + + val targetJarPath = + try { + PluginValidator.resolveStagingTarget(inputPath) + } catch (e: Exception) { + val msg = e.message ?: "Failed to resolve staging target JAR" + logger.error(LogCategory.SYSTEM, msg, error = e) + if (json) { + echo( + buildJsonObject { + put("status", "error") + put("error", msg) + }.toString(), + ) + } else { + echo("Error: $msg", err = true) + } + throw ProgramResult(1) + } + + val targetJarFile = targetJarPath.toFile() + val validation = PluginValidator.validate(targetJarFile) + if (!validation.isValid) { + val errMsg = "Cannot link invalid plugin at ${targetJarFile.absolutePath}" + logger.error(LogCategory.SYSTEM, errMsg) + val report = PluginValidator.toReport(targetJarFile.absolutePath, validation) + if (json) { + echo(launchpadJson.encodeToString(report)) + } else { + echo("$errMsg:", err = true) + validation.checks.filter { !it.passed }.forEach { + echo(" [✗] ${it.name}: ${it.message}", err = true) + } + } + throw ProgramResult(1) + } + + val manifest = + try { + PluginValidator.readManifestFromJar(targetJarFile) + ?: error("Failed to extract plugin manifest from JAR: ${targetJarFile.name}") + } catch (e: Exception) { + val msg = "Failed to extract plugin manifest from JAR: ${e.message}" + logger.error(LogCategory.SYSTEM, msg, error = e) + if (json) { + echo( + buildJsonObject { + put("status", "error") + put("error", msg) + }.toString(), + ) + } else { + echo("Error: $msg", err = true) + } + throw ProgramResult(1) + } + val pluginId = manifest.pluginId + + // Version-rotated staging to prevent Windows file locking collisions + val pluginDevBase = File(DevPluginArtifacts.stagingRoot(), pluginId) + val timestamp = System.currentTimeMillis() + val versionDir = File(pluginDevBase, "v$timestamp") + if (!versionDir.exists() && !versionDir.mkdirs()) { + val msg = "Failed to create version staging directory: ${versionDir.absolutePath}" + logger.error(LogCategory.SYSTEM, msg) + if (json) { + echo( + buildJsonObject { + put("status", "error") + put("error", msg) + }.toString(), + ) + } else { + echo("Error: $msg", err = true) + } + throw ProgramResult(1) + } + val partJar = File(versionDir, "$pluginId.jar.part") + val stagedJar = File(versionDir, "$pluginId.jar") + + try { + targetJarFile.copyTo(partJar, overwrite = true) + try { + Files.move( + partJar.toPath(), + stagedJar.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch ( + @Suppress("SwallowedException") e: AtomicMoveNotSupportedException, + ) { + logger.debug( + LogCategory.SYSTEM, + "ATOMIC_MOVE not supported, falling back to REPLACE_EXISTING", + mapOf("pluginId" to pluginId), + ) + Files.move( + partJar.toPath(), + stagedJar.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + } catch (e: Exception) { + val msg = "Failed to stage plugin in ${stagedJar.absolutePath}: ${e.message}" + logger.error(LogCategory.SYSTEM, msg, error = e) + if (json) { + echo( + buildJsonObject { + put("status", "error") + put("error", msg) + }.toString(), + ) + } else { + echo("Error: $msg", err = true) + } + throw ProgramResult(1) + } + + val reloadResult = SingleInstanceManager.reloadDevPlugin(pluginId) + when (reloadResult) { + is ReloadResult.Success -> { + if (json) { + val payload = + buildJsonObject { + put("status", "linked_and_reloaded") + put("pluginId", pluginId) + put("running", true) + put("stagedPath", stagedJar.absolutePath.replace('\\', '/')) + } + echo(payload.toString()) + } else { + echo( + "[✓] Plugin '$pluginId' staged at ${stagedJar.absolutePath} " + + "and reload signal confirmed by BossConsole.", + ) + } + } + + is ReloadResult.HostOffline -> { + if (json) { + val payload = + buildJsonObject { + put("status", "staged") + put("pluginId", pluginId) + put("running", false) + put("stagedPath", stagedJar.absolutePath.replace('\\', '/')) + put("notice", "BossConsole is offline; plugin staged for next launch") + } + echo(payload.toString()) + } else { + echo( + "[✓] Plugin '$pluginId' staged at ${stagedJar.absolutePath}. " + + "BossConsole is offline; plugin staged for next launch.", + ) + } + } + + is ReloadResult.TimedOut -> { + if (json) { + val payload = + buildJsonObject { + put("status", "staged_unconfirmed") + put("pluginId", pluginId) + put("running", true) + put("stagedPath", stagedJar.absolutePath.replace('\\', '/')) + put("notice", reloadResult.message) + } + echo(payload.toString()) + } else { + echo( + "[~] Plugin '$pluginId' staged at ${stagedJar.absolutePath}. " + + "${reloadResult.message}. It is picked up at the next launch.", + ) + } + } + + is ReloadResult.Failed -> { + val msg = "Plugin '$pluginId' staged, but reload failed: ${reloadResult.reason}" + logger.error(LogCategory.SYSTEM, msg) + if (json) { + val payload = + buildJsonObject { + put("status", "linked_reload_failed") + put("pluginId", pluginId) + put("running", true) + put("error", reloadResult.reason) + put("stagedPath", stagedJar.absolutePath.replace('\\', '/')) + } + echo(payload.toString()) + throw ProgramResult(1) + } else { + echo(msg, err = true) + throw ProgramResult(1) + } + } + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt index 4350a7784b..baea0ae9bc 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt @@ -24,6 +24,7 @@ import ai.rever.boss.logging.GlobalLogCapture import ai.rever.boss.performance.MemoryPressureWatchdog import ai.rever.boss.performance.PerformanceMonitor import ai.rever.boss.plugin.PluginStoreSetup +import ai.rever.boss.plugin.launchpad.DevPluginReloader import ai.rever.boss.plugin.sandbox.PluginExecutionBoundary import ai.rever.boss.plugin.sandbox.ui.PluginCrashInterceptor import ai.rever.boss.plugin.sandbox.ui.PluginCrashRegistry @@ -74,6 +75,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import java.awt.Window import javax.swing.JPopupMenu import kotlin.system.exitProcess @@ -359,7 +361,18 @@ fun main(args: Array) { ai.rever.boss.components.plugin.DefaultPlugin.Companion.loadPersistedPluginsInternal = { manager -> PluginStoreSetup.loadPersistedPlugins(manager) } + val defaultCheck = ai.rever.boss.components.plugin.DefaultPlugin.Companion.isAuthoritativeSystemPlugin + ai.rever.boss.components.plugin.DefaultPlugin.Companion.isAuthoritativeSystemPlugin = { pluginId -> + defaultCheck(pluginId) || PluginStoreSetup.isSystemPluginId(pluginId) + } + // Set up single-instance development reload handler + SingleInstanceManager.pluginReloadHandlerOverride = { pluginId -> + runBlocking { + DevPluginReloader.reload(pluginId).getOrThrow() + true + } + } GlobalLogCapture.start() ResourceModeConfig.publishToPlugins() diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginBuildProbe.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginBuildProbe.kt index ae012e44cc..df15584cd3 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginBuildProbe.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginBuildProbe.kt @@ -1,6 +1,7 @@ package ai.rever.boss.plugin import ai.rever.boss.components.plugin.PluginBuildInfo +import ai.rever.boss.plugin.launchpad.DevPluginArtifacts import ai.rever.boss.plugin.loader.PluginSignatureSidecar import ai.rever.boss.utils.logging.BossLogger import ai.rever.boss.utils.logging.LogCategory @@ -118,7 +119,11 @@ object PluginBuildProbe { reloadStamp = reloadStamp, ) - hooks.record(pluginId, jarPath, mtime ?: previous?.buildStamp, tagFor(info), plugin.version) + if (!DevPluginArtifacts + .isDevPluginJar(File(jarPath)) + ) { + hooks.record(pluginId, jarPath, mtime ?: previous?.buildStamp, tagFor(info), plugin.version) + } if (info.isTagged) { logger.info( diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginStoreSetup.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginStoreSetup.kt index 0accf9b9af..b5c53ab5a2 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginStoreSetup.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginStoreSetup.kt @@ -1,7 +1,14 @@ package ai.rever.boss.plugin +import ai.rever.boss.components.plugin.DefaultPlugin +import ai.rever.boss.components.plugin.DynamicPluginInfo +import ai.rever.boss.components.plugin.DynamicPluginManager +import ai.rever.boss.components.plugin.HotReloadPolicy +import ai.rever.boss.components.plugin.PersistedPluginEntry import ai.rever.boss.config.GitHubConfig import ai.rever.boss.config.SupabaseClientConfig +import ai.rever.boss.plugin.api.PluginState +import ai.rever.boss.plugin.launchpad.DevPluginArtifacts import ai.rever.boss.plugin.loader.PluginBundledTrust import ai.rever.boss.plugin.loader.PluginManifestReader import ai.rever.boss.plugin.loader.PluginSignatureEnforcement @@ -1724,16 +1731,12 @@ object PluginStoreSetup { ), ) - val entries = - persistedPlugins.map { entry -> - ai.rever.boss.components.plugin.PersistedPluginEntry( - pluginId = entry.pluginId, - jarPath = entry.jarPath, - enabled = entry.enabled, - ) - } - - val persistedResults = dynamicPluginManager.loadPersistedPlugins(entries) + val persistedResults = + loadPersistedPluginEntries( + dynamicPluginManager = dynamicPluginManager, + persistedPlugins = persistedPlugins, + devRoot = DevPluginArtifacts.stagingRoot(), + ) results.putAll(persistedResults) // Repoint installed.json at what actually loaded: a stale persisted @@ -1746,7 +1749,10 @@ object PluginStoreSetup { for ((pluginId, result) in persistedResults) { val loaded = result.getOrNull() ?: continue val persisted = persistedById[pluginId] ?: continue - if (persisted.jarPath != loaded.jarPath) { + if (persisted.jarPath != loaded.jarPath && + !DevPluginArtifacts + .isDevPluginJar(File(loaded.jarPath)) + ) { PluginPersistence.addInstalledPlugin( pluginId = pluginId, jarPath = loaded.jarPath, @@ -1777,6 +1783,136 @@ object PluginStoreSetup { return results } + internal suspend fun loadPersistedPluginEntries( + dynamicPluginManager: DynamicPluginManager, + persistedPlugins: List, + devRoot: File = DevPluginArtifacts.stagingRoot(), + ): Map> { + val entries = + persistedPlugins.map { entry -> + PersistedPluginEntry( + pluginId = entry.pluginId, + jarPath = resolvePersistedEntryPath(entry, devRoot), + enabled = entry.enabled, + ) + } + + val initialResults = dynamicPluginManager.loadPersistedPlugins(entries) + val finalResults = initialResults.toMutableMap() + val persistedById = persistedPlugins.associateBy { it.pluginId } + + for ((pluginId, result) in initialResults) { + val entry = persistedById[pluginId] ?: continue + val attemptedPath = entries.firstOrNull { it.pluginId == pluginId }?.jarPath + val isDevSwap = attemptedPath != null && attemptedPath != entry.jarPath + val loadedInfo = result.getOrNull() + val isFailedDevSwap = + result.isFailure || ( + isDevSwap && entry.enabled && + loadedInfo?.state == PluginState.DISABLED && + dynamicPluginManager.canAccess(loadedInfo.manifest) + ) + + if (isFailedDevSwap) { + val fallbackResult = + attemptStoreFallbackOnDevFailure( + dynamicPluginManager = dynamicPluginManager, + pluginId = pluginId, + entry = entry, + attemptedPath = attemptedPath, + devError = + result.exceptionOrNull() + ?: IllegalStateException( + result.getOrNull()?.errorMessage ?: "Dev plugin loaded in DISABLED state", + ), + ) + if (fallbackResult != null) { + finalResults[pluginId] = fallbackResult + } + } + } + return finalResults + } + + private suspend fun attemptStoreFallbackOnDevFailure( + dynamicPluginManager: DynamicPluginManager, + pluginId: String, + entry: PluginPersistence.InstalledPluginEntry, + attemptedPath: String?, + devError: Throwable?, + ): Result? { + val isDevBuildSwap = + attemptedPath != null && + attemptedPath != entry.jarPath && + DevPluginArtifacts.isDevPluginJar(File(attemptedPath)) + val storeJar = File(entry.jarPath) + if (!isDevBuildSwap || !storeJar.exists()) { + return null + } + + logger.warn( + LogCategory.SYSTEM, + "Dev plugin failed to load at startup; falling back to store build", + mapOf( + "pluginId" to pluginId, + "devJarPath" to attemptedPath, + "storeJarPath" to entry.jarPath, + "error" to (devError?.message ?: "unknown"), + ), + devError, + ) + val fallbackResult = + dynamicPluginManager.installPlugin(entry.jarPath, enabled = entry.enabled) + if (fallbackResult.isSuccess) { + logger.info( + LogCategory.SYSTEM, + "Successfully fell back to store build for plugin $pluginId", + mapOf("pluginId" to pluginId, "storeJarPath" to entry.jarPath), + ) + } else { + logger.error( + LogCategory.SYSTEM, + "Fallback to store build also failed for plugin $pluginId", + mapOf("pluginId" to pluginId, "storeJarPath" to entry.jarPath), + fallbackResult.exceptionOrNull(), + ) + } + return fallbackResult + } + + internal fun resolvePersistedEntryPath( + entry: PluginPersistence.InstalledPluginEntry, + devRoot: File = DevPluginArtifacts.stagingRoot(), + ): String { + val devJar = + DevPluginArtifacts.findActiveDevJar( + entry.pluginId, + devRoot, + ) + if (devJar != null && devJar.exists() && !isProtectedFromDevSwap(entry.pluginId)) { + if (DevPluginArtifacts.isValidDevJar(devJar, entry.pluginId)) { + return devJar.absolutePath + } + logger.warn( + LogCategory.SYSTEM, + "Skipping corrupted or incomplete dev build at ${devJar.absolutePath}; falling back to store build", + mapOf( + "pluginId" to entry.pluginId, + "devJarPath" to devJar.absolutePath, + "storeJarPath" to entry.jarPath, + ), + ) + } + return entry.jarPath + } + + internal fun isSystemPluginId(pluginId: String): Boolean = systemPlugins.any { it.pluginId == pluginId } + + private fun isProtectedFromDevSwap(pluginId: String): Boolean = + DefaultPlugin.isAuthoritativeSystemPlugin(pluginId) || + isSystemPluginId(pluginId) || + HotReloadPolicy.requiresRestartInsteadOfHotReload(pluginId) + /** * Copy bundled plugins from app resources to ~/.boss/plugins directory. * Only copies if the plugin is not already installed or if the bundled version is newer. diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloader.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloader.kt new file mode 100644 index 0000000000..b3351005d8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloader.kt @@ -0,0 +1,323 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.components.plugin.DefaultPlugin +import ai.rever.boss.components.plugin.DynamicPluginInfo +import ai.rever.boss.components.plugin.DynamicPluginManager +import ai.rever.boss.components.plugin.HotReloadPolicy +import ai.rever.boss.plugin.api.CanUnloadResult +import ai.rever.boss.plugin.api.PluginState +import ai.rever.boss.plugin.loader.PluginUnloadException +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Executes dev plugin hot-reload by dispatching unload and install requests directly to DynamicPluginManager. + */ +@Suppress("ReturnCount", "ThrowsCount", "TooGenericExceptionCaught") +object DevPluginReloader { + private val logger = BossLogger.forComponent("DevPluginReloader") + private val reloadLocks = ConcurrentHashMap() + private val sessionPreservedPaths = ConcurrentHashMap>() + + internal fun clearSessionPreservedPathsForTest() { + sessionPreservedPaths.clear() + } + + /** + * Reloads the active dev build of [pluginId] into all running host [DynamicPluginManager] instances. + * Preserves native/browser restart policies, runs a dry-run pre-flight check across all managers + * before unloading, unloads running instances without force, loads fresh bytes, and prunes old + * staging directories on the host while strictly retaining any JAR path actively referenced by any manager. + * Executes strictly on [Dispatchers.Main] to prevent UI hierarchy threading deadlocks. + * + * The unload deliberately does not wait for classloader garbage collection: the poll loop + * runs [System.gc] on the caller's thread, and running it here would freeze the UI for up + * to the GC watcher's timeout. Every host reload path takes the same default; the old + * loader is reclaimed out of band once nothing references it. + */ + suspend fun reload( + pluginId: String, + devRoot: File = DevPluginArtifacts.stagingRoot(), + ): Result { + val mutex = reloadLocks.computeIfAbsent(pluginId) { Mutex() } + return mutex.withLock { + withContext(Dispatchers.Main) { + runCatching { + executeReload(pluginId, devRoot) + } + } + } + } + + private suspend fun executeReload( + pluginId: String, + devRoot: File, + ) { + val activeManagers = DynamicPluginManager.activeManagers() + if (activeManagers.isEmpty()) { + error("Host plugin manager is not yet initialized") + } + + if (HotReloadPolicy.requiresRestartInsteadOfHotReload(pluginId)) { + val message = "Plugin $pluginId owns native resources that require a full application restart" + logger.warn(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId)) + error(message) + } + + val stagedJar = + DevPluginArtifacts.findActiveDevJar(pluginId, devRoot) + ?: error("No staged dev JAR found for plugin $pluginId in ${devRoot.absolutePath}") + + recordSessionPreservedPaths(pluginId, listOf(stagedJar.absolutePath)) + + logger.info( + LogCategory.SYSTEM, + "Initiating dev plugin reload", + mapOf( + "pluginId" to pluginId, + "jarPath" to stagedJar.absolutePath, + "managersCount" to activeManagers.size, + ), + ) + + val priorStates = + activeManagers.map { manager -> + val info = manager.getPluginInfo(pluginId) + PriorManagerState( + manager = manager, + priorJarPath = info?.jarPath, + wasLoaded = info?.state == PluginState.LOADED, + wasEnabled = info?.enabled ?: true, + ) + } + val priorPaths = priorStates.mapNotNull { it.priorJarPath } + recordSessionPreservedPaths(pluginId, priorPaths) + + preflightCheck(pluginId, activeManagers) + val modifiedManagers = mutableSetOf() + try { + unloadManagers(pluginId, activeManagers, modifiedManagers) + installManagers(pluginId, stagedJar, activeManagers, priorStates, modifiedManagers) + } catch (e: Exception) { + val rollbackErrors = rollbackManagers(pluginId, priorStates, modifiedManagers) + if (rollbackErrors.isNotEmpty()) { + rollbackErrors.values.forEach { rollbackError -> + e.addSuppressed(rollbackError) + } + } + throw e + } + + pruneStaging(pluginId, devRoot, sessionPreservedPaths[pluginId].orEmpty()) + + logger.info( + LogCategory.SYSTEM, + "Dev plugin reloaded successfully", + mapOf("pluginId" to pluginId, "jarPath" to stagedJar.absolutePath), + ) + } + + internal data class PriorManagerState( + val manager: DynamicPluginManager, + val priorJarPath: String?, + val wasLoaded: Boolean, + val wasEnabled: Boolean, + ) + + internal suspend fun rollbackManagers( + pluginId: String, + priorStates: List, + modifiedManagers: Set = priorStates.map { it.manager }.toSet(), + ): Map { + val targets = priorStates.filter { it.manager in modifiedManagers } + logger.warn( + LogCategory.SYSTEM, + "Rolling back dev plugin reload across modified managers", + mapOf("pluginId" to pluginId, "managersCount" to targets.size), + ) + val rollbackErrors = mutableMapOf() + for (priorState in targets) { + val outcome = rollbackSingleManager(pluginId, priorState) + outcome.exceptionOrNull()?.let { error -> + rollbackErrors[priorState.manager] = error + } + } + return rollbackErrors + } + + private suspend fun rollbackSingleManager( + pluginId: String, + priorState: PriorManagerState, + ): Result = + runCatching { + val hasValidPriorJar = + priorState.priorJarPath != null && + File(priorState.priorJarPath).exists() + if (hasValidPriorJar) { + // Hot-reload case: reinstall the prior working JAR with previous enabled state. + // Always force-uninstall any existing or partially loaded instance first. + val uninstallResult = + priorState.manager.uninstallPlugin(pluginId, force = true, waitForGC = false) + if (uninstallResult.isFailure) { + val error = uninstallResult.exceptionOrNull() + val isNotFound = + (error is PluginUnloadException && error.message?.contains("Plugin not found") == true) || + error?.message?.contains("Plugin not found") == true + if (!isNotFound) { + val message = "Failed to force-uninstall plugin $pluginId during rollback: ${error?.message}" + logger.error(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId), error) + throw IllegalStateException(message, error) + } + } + val installResult = + priorState.manager.installPlugin(priorState.priorJarPath, enabled = priorState.wasEnabled) + val restored = installResult.getOrNull() + if (!isLoadedOrHidden(restored, priorState.wasEnabled, priorState.manager)) { + val error = installResult.exceptionOrNull() + val message = + "Failed to reinstall prior JAR ${priorState.priorJarPath} during rollback: " + + (error?.message ?: "restored plugin is not running (state: ${restored?.state})") + logger.error(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId), error) + throw IllegalStateException(message, error) + } + } else { + // First link case: was newly installed during this reload cycle; + // uninstall it to restore clean initial state. + val uninstallResult = + priorState.manager.uninstallPlugin(pluginId, force = true, waitForGC = false) + if (uninstallResult.isFailure) { + val error = uninstallResult.exceptionOrNull() + val isNotFound = + (error is PluginUnloadException && error.message?.contains("Plugin not found") == true) || + error?.message?.contains("Plugin not found") == true + if (!isNotFound) { + val message = + "Failed to clean up newly installed plugin $pluginId during rollback: ${error?.message}" + logger.error(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId), error) + throw IllegalStateException(message, error) + } + } + } + }.onFailure { e -> + logger.error( + LogCategory.SYSTEM, + "Failed to rollback plugin $pluginId on manager", + mapOf("pluginId" to pluginId), + e, + ) + } + + private suspend fun preflightCheck( + pluginId: String, + managers: List, + ) { + if (DefaultPlugin.isAuthoritativeSystemPlugin(pluginId)) { + val message = "Plugin '$pluginId' is a protected system plugin and cannot be hot-reloaded" + logger.warn(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId)) + error(message) + } + + for (manager in managers) { + val info = manager.getPluginInfo(pluginId) ?: continue + if (manager.isSystemPlugin(pluginId) || info.manifest.canUnload == false) { + val message = "Plugin '$pluginId' is a protected system plugin" + logger.warn(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId)) + error(message) + } + val canUnloadResult = manager.checkCanUnload(pluginId) + if (canUnloadResult is CanUnloadResult.NotAllowed) { + val reasons = canUnloadResult.reasons.joinToString(", ") + val message = "Cannot unload '$pluginId' due to active dependents: $reasons" + logger.warn(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId, "reasons" to reasons)) + error(message) + } + } + } + + private suspend fun unloadManagers( + pluginId: String, + managers: List, + modifiedManagers: MutableSet, + ) { + for (manager in managers) { + if (manager.getPluginInfo(pluginId) != null) { + val unloadResult = manager.uninstallPlugin(pluginId, force = false, waitForGC = false) + if (unloadResult.isFailure) { + val error = unloadResult.exceptionOrNull() + val message = "Failed to unload plugin $pluginId: ${error?.message ?: "unknown"}" + logger.error(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId), error) + throw IllegalStateException(message, error) + } + modifiedManagers.add(manager) + } + } + } + + private suspend fun installManagers( + pluginId: String, + stagedJar: File, + managers: List, + priorStates: List, + modifiedManagers: MutableSet, + ) { + val priorByManager = priorStates.associateBy { it.manager } + for (manager in managers) { + modifiedManagers.add(manager) + val targetEnabled = priorByManager[manager]?.wasEnabled ?: true + val installResult = manager.installPlugin(stagedJar.absolutePath, enabled = targetEnabled) + val installed = installResult.getOrNull() + if (!isLoadedOrHidden(installed, targetEnabled, manager)) { + val error = installResult.exceptionOrNull() + val state = installed?.state?.name ?: "unknown" + val message = + "Staged dev JAR for $pluginId is not running (state: $state): " + + "${error?.message ?: "installation did not report a loaded plugin"}" + logger.error(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId), error) + throw IllegalStateException(message, error) + } + } + } + + private fun recordSessionPreservedPaths( + pluginId: String, + paths: Collection, + ) { + if (paths.isEmpty()) return + sessionPreservedPaths + .computeIfAbsent(pluginId) { ConcurrentHashMap.newKeySet() } + .addAll(paths) + } + + private fun pruneStaging( + pluginId: String, + devRoot: File, + preservedPaths: Set = emptySet(), + ) { + val activeJarPaths = + DynamicPluginManager + .activeManagers() + .mapNotNull { it.getPluginInfo(pluginId)?.jarPath } + .toSet() + preservedPaths + val pluginDevDir = DevPluginArtifacts.pluginDevDir(pluginId, devRoot) + DevPluginArtifacts.pruneStagingHistory( + pluginDevDir = pluginDevDir, + activeJarPaths = activeJarPaths, + ) + } +} + +private fun isLoadedOrHidden( + installed: DynamicPluginInfo?, + enabled: Boolean, + manager: DynamicPluginManager, +): Boolean = + installed != null && ( + installed.state == PluginState.LOADED || + (installed.state == PluginState.DISABLED && (!enabled || !manager.canAccess(installed.manifest))) + ) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginManifest.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginManifest.kt new file mode 100644 index 0000000000..b9c080ac43 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginManifest.kt @@ -0,0 +1,209 @@ +package ai.rever.boss.plugin.launchpad + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Canonical permission vocabulary supported by BossConsole host runtime. + */ +enum class PluginPermission( + val identifier: String, +) { + NETWORK("network"), + FILESYSTEM("filesystem"), + TERMINAL("terminal"), + BROWSER("browser"), + NOTIFICATIONS("notifications"), + AUTH("auth"), + MCP("mcp"), + EDITOR("editor"), + CLIPBOARD("clipboard"), + SETTINGS("settings"), + SYSTEM("system"), + STORAGE("storage"), + ; + + companion object { + private val lookup = entries.associateBy { it.identifier } + + fun isValid(name: String): Boolean = lookup.containsKey(name) + + fun fromIdentifier(name: String): PluginPermission? = lookup[name] + } +} + +/** + * Manifest schema for BossConsole plugins (matching META-INF/boss-plugin/plugin.json). + * Supports canonical fields and aliases for backward-compatibility. + */ +@Serializable +data class PluginManifest( + @SerialName("pluginId") + val pluginId: String = "", + @SerialName("displayName") + val displayName: String = "", + val version: String = "1.0.0", + @SerialName("apiVersion") + val apiVersion: String = HostMeta.CURRENT_API_VERSION, + @SerialName("mainClass") + val mainClass: String = "", + val description: String = "", + val author: String = "", + val license: String = "Apache-2.0", + val manifestVersion: Int = 1, + @SerialName("systemPlugin") + val systemPlugin: Boolean = false, + @SerialName("canUnload") + val canUnload: Boolean = true, + @SerialName("requiredPermissions") + val requiredPermissions: List = emptyList(), + val mcpTools: List = emptyList(), + // Backward-compatibility aliases for legacy JSON fields + @SerialName("id") + private val legacyId: String? = null, + @SerialName("name") + private val legacyName: String? = null, + @SerialName("minApiVersion") + private val legacyMinApiVersion: String? = null, + @SerialName("entrypointClass") + private val legacyEntrypointClass: String? = null, + @SerialName("permissions") + private val legacyPermissions: List? = null, +) { + val id: String get() = pluginId.ifBlank { legacyId.orEmpty() } + val name: String get() = displayName.ifBlank { legacyName.orEmpty() } + val minApiVersion: String get() = apiVersion.ifBlank { legacyMinApiVersion.orEmpty() } + val entrypointClass: String get() = mainClass.ifBlank { legacyEntrypointClass.orEmpty() } + val permissions: List get() = requiredPermissions.ifEmpty { legacyPermissions.orEmpty() } + + /** Returns the canonical plugin ID. */ + fun resolvedPluginId(): String = pluginId.ifBlank { legacyId.orEmpty() } + + /** Returns the canonical display name. */ + fun resolvedDisplayName(): String = displayName.ifBlank { legacyName.orEmpty() } + + /** Returns the canonical entrypoint class. */ + fun resolvedMainClass(): String = mainClass.ifBlank { legacyEntrypointClass.orEmpty() } + + /** Returns the canonical API version. */ + fun resolvedApiVersion(): String = apiVersion.ifBlank { legacyMinApiVersion.orEmpty() } +} + +@Serializable +data class PluginMcpToolDeclaration( + val name: String, + val description: String, + val adminOnly: Boolean = false, +) + +@Serializable +data class ValidationCheck( + val name: String, + val passed: Boolean, + val message: String, +) + +@Serializable +data class ValidationResult( + val isValid: Boolean, + val checks: List, +) + +@Serializable +data class ValidationFailure( + val checkName: String, + val message: String, +) + +@Serializable +data class ValidationReport( + val success: Boolean, + val targetPath: String, + val checksPassed: Int, + val totalChecks: Int, + val failures: List = emptyList(), + val checks: List = emptyList(), +) + +object HostMeta { + val CURRENT_API_VERSION: String = resolveCurrentApiVersion() + + // VersionConstants is generated from libs.versions.toml, so the fallback + // here is a compile-time constant, not a second hand-copied version. + private fun resolveCurrentApiVersion(): String = + System.getProperty("boss.plugin.api.version") + ?: ai.rever.boss.utils.VersionConstants.PLUGIN_API_VERSION + + /** + * Standard RBAC permission format: dot/dash/underscore-separated alphanumeric tokens + * (e.g. 'plugins.create', 'secret.read'). + */ + val PERMISSION_REGEX: Regex = Regex("""^[a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*$""") + + fun isValidPermissionIdentifier(permission: String): Boolean { + val trimmed = permission.trim() + return trimmed.isNotEmpty() && PERMISSION_REGEX.matches(trimmed) + } + + @Deprecated("Permissions in BOSS correspond to dynamic host RBAC permissions rather than a fixed enum.") + val ALLOWED_PERMISSIONS: Set = + PluginPermission.entries.map { it.identifier }.toSet() +} + +/** + * SemVer 2.0 conformance and compatibility validator. + */ +object SemVerValidator { + val SEMVER_REGEX = + Regex( + """^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)""" + + """(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?""" + + """(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$""", + ) + + fun isValid(version: String): Boolean = SEMVER_REGEX.matches(version) + + fun isCompatible( + requiredMin: String, + hostVersion: String, + ): Boolean { + if (!isValid(requiredMin) || !isValid(hostVersion)) return false + val reqParts = + requiredMin + .split('-')[0] + .split('+')[0] + .split('.') + .map { it.toIntOrNull() ?: 0 } + val hostParts = + hostVersion + .split('-')[0] + .split('+')[0] + .split('.') + .map { it.toIntOrNull() ?: 0 } + + val rMajor = reqParts.getOrElse(0) { 0 } + val rMinor = reqParts.getOrElse(1) { 0 } + val rPatch = reqParts.getOrElse(2) { 0 } + + val hMajor = hostParts.getOrElse(0) { 0 } + val hMinor = hostParts.getOrElse(1) { 0 } + val hPatch = hostParts.getOrElse(2) { 0 } + + return when { + hMajor != rMajor -> false + hMinor > rMinor -> true + hMinor == rMinor -> hPatch >= rPatch + else -> false + } + } +} + +val launchpadJson = + Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + encodeDefaults = true + explicitNulls = false + } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginScaffolder.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginScaffolder.kt new file mode 100644 index 0000000000..14c2a7cbeb --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginScaffolder.kt @@ -0,0 +1,734 @@ +package ai.rever.boss.plugin.launchpad + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +/** + * Scaffolds third-party plugin projects with standard structure, Gradle wrapper, build, and tests. + */ +@Suppress("LongMethod", "TooManyFunctions", "ComplexCondition", "LargeClass") +object PluginScaffolder { + enum class Template( + val type: String, + ) { + MCP_TOOL("mcp-tool"), + UI_PANEL("ui-panel"), + BACKGROUND_SERVICE("background-service"), + FULL("full"), + ; + + companion object { + fun fromString(value: String): Template { + val normalized = value.lowercase().trim() + return entries.firstOrNull { it.type == normalized } + ?: throw IllegalArgumentException( + "Unknown template '$value'. Supported: mcp-tool, ui-panel, background-service, full", + ) + } + } + } + + data class ScaffoldResult( + val pluginId: String, + val targetDirectory: File, + val filesCreated: List, + ) + + @Suppress("CyclomaticComplexMethod", "LongMethod") + fun scaffold( + name: String, + templateName: String = "mcp-tool", + targetDir: File, + force: Boolean = false, + ): ScaffoldResult { + val template = Template.fromString(templateName) + + if (targetDir.exists() && targetDir.isDirectory) { + val existingFiles = targetDir.listFiles() + if (existingFiles?.isNotEmpty() == true) { + if (!force) { + val path = targetDir.absolutePath + error("Target directory '$path' exists and is not empty. Use --force to overwrite.") + } + assertSafeToPurge(targetDir) + existingFiles.forEach { it.deleteRecursively() } + } + } + + if (!targetDir.exists() && !targetDir.mkdirs()) { + throw IOException("Failed to create directory: ${targetDir.absolutePath}") + } + + val baseSuffix = + if (name.contains('.')) { + name.substringAfterLast('.').ifBlank { "custom-plugin" } + } else { + name + } + val rawSuffix = + baseSuffix + .trim() + .lowercase() + .replace(Regex("[^a-z0-9-]"), "-") + .replace(Regex("-+"), "-") + .trim('-') + .ifBlank { "custom-plugin" } + + val pluginId = derivePluginId(name) + + val className = sanitizeClassIdentifier(name) + val packageSuffix = sanitizePackageIdentifier(rawSuffix) + val packageName = "com.example.$packageSuffix" + val packageDirRel = "com/example/$packageSuffix" + + // Starter templates are unconstrained by default so any authenticated non-admin developer + // can load and test them without hitting RBAC permission gating (pluginAccessAllowed). + val requiredPermissions = emptyList() + + val mcpTools = + when (template) { + Template.MCP_TOOL, Template.FULL -> { + listOf( + PluginMcpToolDeclaration( + name = "mcp__${pluginId.replace('.', '_').replace('-', '_')}__action", + description = "Executes $name action tool", + adminOnly = false, + ), + ) + } + + else -> { + emptyList() + } + } + + val manifest = + PluginManifest( + pluginId = pluginId, + displayName = name, + version = "0.1.0", + description = "BossConsole plugin for $name ($templateName template)", + author = "Boss Developer", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "$packageName.$className", + manifestVersion = 1, + systemPlugin = false, + canUnload = true, + requiredPermissions = requiredPermissions, + mcpTools = mcpTools, + ) + + val filesCreated = mutableListOf() + + // 1. Root plugin.json + val manifestFile = File(targetDir, "plugin.json") + val manifestContent = launchpadJson.encodeToString(manifest) + manifestFile.writeText(manifestContent) + filesCreated += manifestFile + + // 1b. Resource manifest: src/main/resources/META-INF/boss-plugin/plugin.json + val metaInfDir = File(targetDir, "src/main/resources/META-INF/boss-plugin") + metaInfDir.mkdirs() + val resourceManifest = File(metaInfDir, "plugin.json") + resourceManifest.writeText(manifestContent) + filesCreated += resourceManifest + + // 2. build.gradle.kts + val gradleFile = File(targetDir, "build.gradle.kts") + gradleFile.writeText(generateBuildGradle(HostMeta.CURRENT_API_VERSION)) + filesCreated += gradleFile + + // 2b. libs directory for local development fallback + val libsDir = File(targetDir, "libs") + libsDir.mkdirs() + + // 3. settings.gradle.kts + val settingsFile = File(targetDir, "settings.gradle.kts") + settingsFile.writeText("rootProject.name = \"$pluginId\"\n") + filesCreated += settingsFile + + // 4. Gradle wrapper files: gradle/wrapper/gradle-wrapper.properties and gradle-wrapper.jar + val wrapperDir = File(targetDir, "gradle/wrapper") + wrapperDir.mkdirs() + val wrapperPropsFile = File(wrapperDir, "gradle-wrapper.properties") + wrapperPropsFile.writeText(generateGradleWrapperProperties()) + filesCreated += wrapperPropsFile + + val wrapperJarFile = File(wrapperDir, "gradle-wrapper.jar") + copyOrGenerateWrapperJar(wrapperJarFile) + filesCreated += wrapperJarFile + + // 5. gradlew (POSIX executable script) + val gradlewFile = File(targetDir, "gradlew") + val gradlewContent = generateGradlew().replace("\r\n", "\n").replace("\r", "\n") + gradlewFile.writeBytes(gradlewContent.toByteArray(Charsets.UTF_8)) + setExecutablePermissions(gradlewFile) + filesCreated += gradlewFile + + // 6. gradlew.bat (Windows batch script) + val gradlewBatFile = File(targetDir, "gradlew.bat") + gradlewBatFile.writeText(generateGradlewBat()) + filesCreated += gradlewBatFile + + // 7. Entrypoint source file: src/main/kotlin//.kt + val srcDir = File(targetDir, "src/main/kotlin/$packageDirRel") + srcDir.mkdirs() + val entrypointFile = File(srcDir, "$className.kt") + entrypointFile.writeText(generateSourceFile(packageName, className, name, pluginId, template)) + filesCreated += entrypointFile + + // 8. Sample test file: src/test/kotlin//Test.kt + val testDir = File(targetDir, "src/test/kotlin/$packageDirRel") + testDir.mkdirs() + val testFile = File(testDir, "${className}Test.kt") + testFile.writeText(generateTestFile(packageName, className, name, pluginId)) + filesCreated += testFile + + // 9. .gitignore + val gitignoreFile = File(targetDir, ".gitignore") + gitignoreFile.writeText(generateGitignore()) + filesCreated += gitignoreFile + + // 10. README.md + val readmeFile = File(targetDir, "README.md") + readmeFile.writeText(generateReadme(name, pluginId, template.type, HostMeta.CURRENT_API_VERSION)) + filesCreated += readmeFile + + return ScaffoldResult(pluginId, targetDir, filesCreated) + } + + private fun setExecutablePermissions(file: File) { + try { + file.setExecutable(true, false) + } catch (_: Exception) { + } + try { + val perms = + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_EXECUTE, + ) + Files.setPosixFilePermissions(file.toPath(), perms) + } catch (_: Exception) { + } + } + + private fun copyOrGenerateWrapperJar(targetJar: File) { + val resourceStream = + PluginScaffolder::class.java.getResourceAsStream("/launcher/gradle-wrapper.jar") + ?: error("Resource /launcher/gradle-wrapper.jar not found in host resources") + resourceStream.use { input -> + targetJar.outputStream().use { output -> + input.copyTo(output) + } + } + } + + private fun generateGradleWrapperProperties(): String = + """ + distributionBase=GRADLE_USER_HOME + distributionPath=wrapper/dists + distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip + zipStoreBase=GRADLE_USER_HOME + zipStorePath=wrapper/dists + """.trimIndent() + "\n" + + private fun generateGradlew(): String = + """ + #!/bin/sh + # Minimal gradlew launcher + set -e + PRG="${'$'}0" + while [ -h "${'$'}PRG" ]; do + link="${'$'}(readlink "${'$'}PRG" 2>/dev/null || true)" + if [ -z "${'$'}link" ]; then + break + fi + case "${'$'}link" in + /*) PRG="${'$'}link" ;; + *) PRG="${'$'}(dirname "${'$'}PRG")/${'$'}link" ;; + esac + done + APP_HOME="${'$'}(cd "${'$'}(dirname "${'$'}PRG")" >/dev/null 2>&1 && pwd)" + exec java -jar "${'$'}APP_HOME/gradle/wrapper/gradle-wrapper.jar" "${'$'}@" + """.trimIndent() + "\n" + + private fun generateGradlewBat(): String = + """ + @rem Minimal gradlew.bat launcher + @if "%DEBUG%"=="" @echo off + set DIRNAME=%~dp0 + if "%DIRNAME%"=="" set DIRNAME=. + java -jar "%DIRNAME%gradle\wrapper\gradle-wrapper.jar" %* + """.trimIndent() + "\n" + + private fun generateBuildGradle(apiVersion: String): String = + """ + plugins { + kotlin("jvm") version "2.0.21" + kotlin("plugin.serialization") version "2.0.21" + `java-library` + } + + kotlin { + compilerOptions { + freeCompilerArgs.add("-Xskip-metadata-version-check") + } + } + + repositories { + mavenCentral() + mavenLocal() + ivy { + url = uri("https://github.com/risa-labs-inc/boss-plugin-api/releases/download") + patternLayout { + artifact("v[revision]/[artifact]-[revision].[ext]") + artifact("[revision]/[artifact]-[revision].[ext]") + } + metadataSources { artifact() } + } + flatDir { + dirs("libs") + } + } + + dependencies { + compileOnly("ai.rever.boss:boss-plugin-api:$apiVersion") + testImplementation("ai.rever.boss:boss-plugin-api:$apiVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + testImplementation(kotlin("test")) + testImplementation(kotlin("test-junit5")) + } + + tasks.test { + useJUnitPlatform() + } + """.trimIndent() + "\n" + + private fun escapeString(raw: String): String = + raw + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\\$") + .replace("\r", "") + .replace("\n", " ") + + private fun generateSourceFile( + packageName: String, + className: String, + pluginName: String, + pluginId: String, + template: Template, + ): String { + val escapedName = escapeString(pluginName) + val sanitizedToolName = pluginId.replace('-', '_').replace('.', '_') + + return when (template) { + Template.MCP_TOOL -> { + """ + package $packageName + + import ai.rever.boss.plugin.api.McpToolDefinition + import ai.rever.boss.plugin.api.McpToolHandler + import ai.rever.boss.plugin.api.McpToolProvider + import ai.rever.boss.plugin.api.McpToolResult + import ai.rever.boss.plugin.api.Plugin + import ai.rever.boss.plugin.api.PluginContext + + class $className : Plugin { + override val pluginId: String = "$pluginId" + override val displayName: String = "$escapedName" + + private var toolProvider: McpToolProvider? = null + + override fun register(context: PluginContext) { + val provider = + object : McpToolProvider { + override val providerId: String = pluginId + + override fun tools(): List = + listOf( + McpToolDefinition( + name = "mcp__${sanitizedToolName}__action", + description = "Executes $escapedName action tool", + handler = + McpToolHandler { _ -> + McpToolResult("Action executed successfully for $escapedName") + }, + ), + ) + } + toolProvider = provider + context.registerMcpToolProvider(provider) + } + + override fun dispose() { + toolProvider = null + println("Disposed $escapedName ($pluginId)") + } + } + """.trimIndent() + "\n" + } + + Template.UI_PANEL -> { + """ + package $packageName + + import ai.rever.boss.plugin.api.PanelId + import ai.rever.boss.plugin.api.PanelMenuContribution + import ai.rever.boss.plugin.api.PanelMenuItem + import ai.rever.boss.plugin.api.Plugin + import ai.rever.boss.plugin.api.PluginContext + + class $className : Plugin { + override val pluginId: String = "$pluginId" + override val displayName: String = "$escapedName" + + private var menuContribution: PanelMenuContribution? = null + + override fun register(context: PluginContext) { + val contribution = + object : PanelMenuContribution { + override val contributionId: String = "$pluginId.menu" + override val targetPanels: Set = emptySet() + + override fun items(panelId: PanelId): List = + listOf( + PanelMenuItem( + id = "$pluginId.action", + label = "$escapedName Action", + ), + ) + + override fun onItemClick( + panelId: PanelId, + itemId: String, + windowId: String?, + ) { + println("Panel menu item clicked: ${'$'}itemId for $escapedName") + } + } + menuContribution = contribution + context.registerPanelMenuContribution(contribution) + } + + override fun dispose() { + menuContribution = null + println("Disposed $escapedName ($pluginId)") + } + } + """.trimIndent() + "\n" + } + + Template.BACKGROUND_SERVICE -> { + """ + package $packageName + + import ai.rever.boss.plugin.api.Plugin + import ai.rever.boss.plugin.api.PluginContext + import kotlinx.coroutines.Job + import kotlinx.coroutines.delay + import kotlinx.coroutines.isActive + import kotlinx.coroutines.launch + + class $className : Plugin { + override val pluginId: String = "$pluginId" + override val displayName: String = "$escapedName" + + private var workerJob: Job? = null + + override fun register(context: PluginContext) { + workerJob = + context.pluginScope.launch { + println("Background service started for $escapedName ($pluginId)") + while (isActive) { + delay(30_000) + println("Background heartbeat for $escapedName") + } + } + } + + override fun dispose() { + workerJob?.cancel() + workerJob = null + println("Background service stopped for $escapedName ($pluginId)") + } + } + """.trimIndent() + "\n" + } + + Template.FULL -> { + """ + package $packageName + + import ai.rever.boss.plugin.api.McpToolDefinition + import ai.rever.boss.plugin.api.McpToolHandler + import ai.rever.boss.plugin.api.McpToolProvider + import ai.rever.boss.plugin.api.McpToolResult + import ai.rever.boss.plugin.api.PanelId + import ai.rever.boss.plugin.api.PanelMenuContribution + import ai.rever.boss.plugin.api.PanelMenuItem + import ai.rever.boss.plugin.api.Plugin + import ai.rever.boss.plugin.api.PluginContext + import kotlinx.coroutines.Job + import kotlinx.coroutines.delay + import kotlinx.coroutines.isActive + import kotlinx.coroutines.launch + + class $className : Plugin { + override val pluginId: String = "$pluginId" + override val displayName: String = "$escapedName" + + private var toolProvider: McpToolProvider? = null + private var menuContribution: PanelMenuContribution? = null + private var workerJob: Job? = null + + override fun register(context: PluginContext) { + val provider = + object : McpToolProvider { + override val providerId: String = pluginId + + override fun tools(): List = + listOf( + McpToolDefinition( + name = "mcp__${sanitizedToolName}__action", + description = "Executes $escapedName action tool", + handler = + McpToolHandler { _ -> + McpToolResult("Action executed successfully for $escapedName") + }, + ), + ) + } + toolProvider = provider + context.registerMcpToolProvider(provider) + + val contribution = + object : PanelMenuContribution { + override val contributionId: String = "$pluginId.menu" + override val targetPanels: Set = emptySet() + + override fun items(panelId: PanelId): List = + listOf( + PanelMenuItem( + id = "$pluginId.action", + label = "$escapedName Action", + ), + ) + + override fun onItemClick( + panelId: PanelId, + itemId: String, + windowId: String?, + ) { + println("Panel menu item clicked: ${'$'}itemId for $escapedName") + } + } + menuContribution = contribution + context.registerPanelMenuContribution(contribution) + + workerJob = + context.pluginScope.launch { + println("Full plugin service started for $escapedName ($pluginId)") + while (isActive) { + delay(30_000) + } + } + } + + override fun dispose() { + workerJob?.cancel() + workerJob = null + toolProvider = null + menuContribution = null + println("Disposed full plugin $escapedName ($pluginId)") + } + } + """.trimIndent() + "\n" + } + } + } + + private fun generateTestFile( + packageName: String, + className: String, + pluginName: String, + pluginId: String, + ): String { + val escapedName = escapeString(pluginName) + return """ + package $packageName + + import kotlin.test.Test + import kotlin.test.assertEquals + import kotlin.test.assertNotNull + + class ${className}Test { + @Test + fun testPluginMetadata() { + val plugin = $className() + assertNotNull(plugin) + assertEquals("$pluginId", plugin.pluginId) + assertEquals("$escapedName", plugin.displayName) + } + } + """.trimIndent() + "\n" + } + + private fun generateGitignore(): String = + """ + .gradle/ + build/ + .idea/ + *.iml + .DS_Store + """.trimIndent() + "\n" + + private fun generateReadme( + name: String, + id: String, + template: String, + apiVersion: String, + ): String = + """ + # $name Plugin + + Scaffolded BossConsole plugin project. + + ## Specification + - **Plugin ID**: `$id` + - **Template**: `$template` + - **Target Host API**: `$apiVersion` + + ## Build Instructions + ```bash + ./gradlew build + ``` + + ## Validate & Link + ```bash + boss plugin validate . + boss plugin link . + ``` + """.trimIndent() + "\n" + + private val RESERVED_KEYWORDS = + setOf( + "as", + "break", + "class", + "continue", + "do", + "else", + "false", + "for", + "fun", + "if", + "in", + "interface", + "is", + "null", + "object", + "package", + "return", + "super", + "this", + "throw", + "true", + "try", + "typealias", + "typeof", + "val", + "var", + "when", + "while", + "default", + "switch", + "case", + "const", + "goto", + ) + + fun isReservedKeyword(name: String): Boolean = RESERVED_KEYWORDS.contains(name.lowercase()) + + fun derivePluginId(name: String): String { + val trimmed = name.trim().lowercase() + if (trimmed.contains('.')) { + val cleaned = + trimmed + .replace(Regex("[^a-z0-9._-]"), "-") + .replace(Regex("-+"), "-") + .trim('-', '.') + if (cleaned.isNotEmpty() && cleaned.first().isLetter() && cleaned.contains('.')) { + return cleaned + } + } + val rawSuffix = + trimmed + .replace(Regex("[^a-z0-9-]"), "-") + .replace(Regex("-+"), "-") + .trim('-') + .ifBlank { "custom-plugin" } + return "com.example.$rawSuffix" + } + + fun sanitizePackageIdentifier(rawId: String): String { + val cleaned = rawId.lowercase().replace(Regex("[^a-z0-9]"), "") + val validStart = + if (cleaned.isNotEmpty() && cleaned.first().isDigit()) { + "p$cleaned" + } else { + cleaned.ifBlank { "plugin" } + } + return if (isReservedKeyword(validStart)) "${validStart}_pkg" else validStart + } + + fun sanitizeClassIdentifier(rawName: String): String { + val pascal = + rawName + .trim() + .split(Regex("[^a-zA-Z0-9]")) + .filter { it.isNotBlank() } + .joinToString("") { it.replaceFirstChar { c -> c.uppercase() } } + .ifBlank { "Custom" } + val base = if (pascal.first().isDigit()) "Plugin$pascal" else pascal + return if (base.endsWith("Plugin")) base else "${base}Plugin" + } + + /** + * Prevents catastrophic directory deletion in --force mode by ensuring the directory is not a + * system root, user home, git repository, or arbitrary non-plugin directory. + */ + fun assertSafeToPurge(targetDir: File) { + val canonicalTarget = targetDir.canonicalFile + val userHome = System.getProperty("user.home")?.let { File(it).canonicalFile } + require(userHome == null || canonicalTarget != userHome) { + "Refusing to purge user home directory in --force mode: ${targetDir.absolutePath}" + } + val roots = File.listRoots()?.map { it.canonicalFile } ?: emptyList() + require(roots.none { it == canonicalTarget }) { + "Refusing to purge system root directory in --force mode: ${targetDir.absolutePath}" + } + val gitDir = File(targetDir, ".git") + require(!gitDir.exists()) { + "Refusing to purge directory containing a .git repository in --force mode: ${targetDir.absolutePath}" + } + val hasPluginJson = + File(targetDir, "plugin.json").exists() || + File(targetDir, "src/main/resources/META-INF/boss-plugin/plugin.json").exists() + val hasGradleBuild = + File(targetDir, "build.gradle.kts").exists() || File(targetDir, "build.gradle").exists() + require(hasPluginJson || hasGradleBuild) { + "Refusing to purge non-plugin directory in --force mode: ${targetDir.absolutePath}. " + + "Directory does not contain plugin.json or build.gradle.kts. Clear it manually if intended." + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginValidator.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginValidator.kt new file mode 100644 index 0000000000..1fba5b5cf6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginValidator.kt @@ -0,0 +1,635 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.plugin.api.Plugin +import ai.rever.boss.plugin.loader.PluginManifestReader +import java.io.File +import java.io.IOException +import java.net.URLClassLoader +import java.nio.BufferUnderflowException +import java.nio.ByteBuffer +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.JarFile +import java.util.zip.ZipException + +/** + * Validates a plugin source directory or packaged archive (.jar / .zip). + */ +@Suppress( + "ReturnCount", + "TooGenericExceptionCaught", + "LongMethod", + "ComplexMethod", + "NestedBlockDepth", + "TooManyFunctions", +) +object PluginValidator { + private val ID_REGEX = Regex("^[a-zA-Z][a-zA-Z0-9_-]*(?:\\.[a-zA-Z0-9_-]+)+$") + private val MCP_TOOL_REGEX = Regex("^mcp__[a-zA-Z0-9_-]+__[a-zA-Z0-9_-]+$") + private val CLASS_NAME_REGEX = Regex("""^[a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)+$""") + private const val MAX_CLASSFILE_BYTES: Long = 5_000_000L + + fun validate(target: File): ValidationResult { + val checks = mutableListOf() + + if (!target.exists()) { + checks += + ValidationCheck( + name = "target-exists", + passed = false, + message = "Target path does not exist: ${target.absolutePath}", + ) + return ValidationResult(isValid = false, checks = checks) + } + + checks += + ValidationCheck( + name = "target-exists", + passed = true, + message = "Target path exists: ${target.absolutePath}", + ) + + return if (target.isDirectory) { + validateDirectory(target, checks) + } else { + validateArchive(target, checks) + } + } + + /** + * Resolves the target JAR artifact for staging into BossConsole. + * If [inputPath] is already a JAR, returns it. + * If [inputPath] is a project directory, locates the compiled JAR in `build/libs/`. + */ + fun resolveStagingTarget(inputPath: Path): Path { + if (Files.isRegularFile(inputPath) && inputPath.toString().endsWith(".jar")) { + return inputPath + } + val libsDir = inputPath.resolve("build").resolve("libs") + if (Files.isDirectory(libsDir)) { + val candidates = + Files.list(libsDir).use { stream -> + stream + .filter { + val name = it.fileName.toString() + name.endsWith(".jar") && + !name.endsWith("-sources.jar") && + !name.endsWith("-javadoc.jar") && + !name.endsWith("-plain.jar") && + !name.endsWith("-all.jar") + }.toList() + } + if (candidates.size == 1) { + return candidates.single() + } + if (candidates.size > 1) { + // Disambiguate by directory name or pick the newest modified build + val dirName = + inputPath.fileName + ?.toString() + ?.lowercase() + .orEmpty() + val matchingByName = + candidates.filter { + val name = it.fileName.toString().lowercase() + dirName.isNotEmpty() && name.startsWith(dirName) + } + val pool = if (matchingByName.isNotEmpty()) matchingByName else candidates + val newest = pool.maxByOrNull { Files.getLastModifiedTime(it) } + if (newest != null) return newest + } + } + error("No compiled JAR found in target directory. Run './gradlew build' before linking.") + } + + fun toReport( + targetPath: String, + result: ValidationResult, + ): ValidationReport = + ValidationReport( + success = result.isValid, + targetPath = targetPath.replace('\\', '/'), + checksPassed = result.checks.count { it.passed }, + totalChecks = result.checks.size, + failures = result.checks.filter { !it.passed }.map { ValidationFailure(it.name, it.message) }, + checks = result.checks, + ) + + fun readManifestFromJar(file: File): PluginManifest? = + try { + JarFile(file).use { jar -> + val entry = + jar.getJarEntry("META-INF/boss-plugin/plugin.json") ?: return null + val content = + jar.getInputStream(entry).use { DevPluginArtifacts.readBoundedUtf8String(it) } ?: return null + launchpadJson.decodeFromString(content) + } + } catch (_: Exception) { + null + } + + private fun validateDirectory( + dir: File, + checks: MutableList, + ): ValidationResult { + val resourceManifest = File(dir, "src/main/resources/META-INF/boss-plugin/plugin.json") + val rootManifest = File(dir, "plugin.json") + val manifestFile = + when { + resourceManifest.exists() && resourceManifest.isFile -> resourceManifest + rootManifest.exists() && rootManifest.isFile -> rootManifest + else -> null + } + + if (manifestFile == null) { + checks += + ValidationCheck( + name = "manifest-exists", + passed = false, + message = "plugin.json not found in directory: ${dir.absolutePath}", + ) + return ValidationResult(isValid = false, checks = checks) + } + + checks += + ValidationCheck( + name = "manifest-exists", + passed = true, + message = "plugin.json found at ${manifestFile.relativeTo(dir).path.replace('\\', '/')}", + ) + + val jsonContent = + try { + manifestFile.readText() + } catch (e: IOException) { + checks += + ValidationCheck( + name = "manifest-readable", + passed = false, + message = "Unable to read plugin.json: ${e.message}", + ) + return ValidationResult(isValid = false, checks = checks) + } + + val manifest = parseManifest(jsonContent, checks) ?: return ValidationResult(isValid = false, checks = checks) + validateManifestFields(manifest, checks, jsonContent) + + return ValidationResult(isValid = checks.all { it.passed }, checks = checks) + } + + private fun validateArchive( + file: File, + checks: MutableList, + ): ValidationResult { + val archiveData = + try { + readArchive(file, checks) + } catch (e: Throwable) { + checks += + ValidationCheck( + name = "archive-readable", + passed = false, + message = "Failed to read archive: ${e.message ?: e::class.simpleName}", + ) + return ValidationResult(isValid = false, checks = checks) + } ?: return ValidationResult(isValid = false, checks = checks) + + val manifest = + parseManifest(archiveData.manifestContent, checks) + ?: return ValidationResult(isValid = false, checks = checks) + validateManifestFields(manifest, checks, archiveData.manifestContent) + + // Bytecode verification for entrypointClass + val entrypointBytecodePath = manifest.mainClass.replace('.', '/') + ".class" + val entrypointExists = archiveData.hasEntry(entrypointBytecodePath) + checks += + ValidationCheck( + name = "bytecode-entrypoint", + passed = entrypointExists, + message = + if (entrypointExists) { + "Bytecode entry '$entrypointBytecodePath' verified in archive" + } else { + "Bytecode entry '$entrypointBytecodePath' not found in archive" + }, + ) + + if (entrypointExists) { + val implementsPlugin = verifyImplementsPlugin(file, manifest.mainClass, archiveData) + checks += + ValidationCheck( + name = "bytecode-implements-plugin", + passed = implementsPlugin, + message = + if (implementsPlugin) { + "Entrypoint class '${manifest.mainClass}' implements ai.rever.boss.plugin.api.Plugin" + } else { + "Entrypoint class '${manifest.mainClass}' must implement ai.rever.boss.plugin.api.Plugin" + }, + ) + } + + return ValidationResult(isValid = checks.all { it.passed }, checks = checks) + } + + private data class ArchiveData( + val manifestContent: String, + val hasEntry: (String) -> Boolean, + val entrypointClassBytes: ByteArray?, + ) + + private fun readArchive( + file: File, + checks: MutableList, + ): ArchiveData? = + try { + JarFile(file).use { jarFile -> + val metaInfEntry = jarFile.getJarEntry("META-INF/boss-plugin/plugin.json") + val rootEntry = jarFile.getJarEntry("plugin.json") + + if (metaInfEntry == null && rootEntry != null) { + checks += + ValidationCheck( + name = "manifest-exists", + passed = false, + message = + "plugin.json found at root of archive; must be located at " + + "META-INF/boss-plugin/plugin.json", + ) + return null + } + + val manifestEntry = metaInfEntry + if (manifestEntry == null) { + checks += + ValidationCheck( + name = "manifest-exists", + passed = false, + message = "plugin.json not found in archive: ${file.name}", + ) + return null + } + + checks += + ValidationCheck( + name = "manifest-exists", + passed = true, + message = "plugin.json found in archive", + ) + + val manifestContent = + jarFile.getInputStream(manifestEntry).use { DevPluginArtifacts.readBoundedUtf8String(it) } + if (manifestContent == null) { + checks += + ValidationCheck( + name = "manifest-valid", + passed = false, + message = + "plugin.json exceeds maximum allowed size limit of " + + "${DevPluginArtifacts.MAX_MANIFEST_BYTES / 1024} KB", + ) + return null + } + val allEntries = + jarFile + .entries() + .asSequence() + .map { it.name } + .toSet() + + val mainClass = + Regex(""""(?:mainClass|entrypointClass)"\s*:\s*"([^"]+)"""") + .find(manifestContent) + ?.groupValues + ?.get(1) + + val entrypointBytes = + if (mainClass != null) { + val entrypointPath = mainClass.replace('.', '/') + ".class" + jarFile.getJarEntry(entrypointPath)?.let { entry -> + if (entry.size > MAX_CLASSFILE_BYTES) { + null + } else { + jarFile.getInputStream(entry).use { stream -> + val bytes = stream.readNBytes((MAX_CLASSFILE_BYTES + 1).toInt()) + if (bytes.size > MAX_CLASSFILE_BYTES) null else bytes + } + } + } + } else { + null + } + + ArchiveData( + manifestContent = manifestContent, + hasEntry = { entryName -> entryName in allEntries }, + entrypointClassBytes = entrypointBytes, + ) + } + } catch (e: ZipException) { + checks += + ValidationCheck( + name = "archive-valid", + passed = false, + message = "Corrupt or invalid ZIP/JAR archive: ${e.message}", + ) + null + } + + /** + * Verifies that [mainClass] implements [Plugin]. + * Uses [Class.forName] with initialize = false to avoid running static initializers (). + * Falls back to inspecting classfile bytes for direct interface declaration if dependencies are missing. + */ + private fun verifyImplementsPlugin( + jarFile: File, + mainClass: String, + archiveData: ArchiveData, + ): Boolean { + // Fast-path & isolation safety: check direct interface implementation from classfile bytes first + // to avoid parent-first delegation falsely loading a host-side class with the same name. + val classBytes = archiveData.entrypointClassBytes + if (classBytes != null && checkDirectInterfaceImplementation(classBytes)) { + return true + } + + return try { + val parentLoader = Plugin::class.java.classLoader + // The classloader holds the jar open (notably on Windows), so it is + // closed as soon as the assignability question is answered. + URLClassLoader(arrayOf(jarFile.toURI().toURL()), parentLoader).use { urlClassLoader -> + // initialize = false ensures static initializers () are never executed + val clazz = Class.forName(mainClass, false, urlClassLoader) + Plugin::class.java.isAssignableFrom(clazz) + } + } catch (_: Throwable) { + // Manual classfile fallback when classloading fails due to external dependencies. + // Note: Manual classfile parser supports direct implementations of ai.rever.boss.plugin.api.Plugin. + if (classBytes != null) { + checkDirectInterfaceImplementation(classBytes) + } else { + false + } + } + } + + /** + * Parses the constant pool and interfaces of [classBytes] to determine if it directly implements + * ai.rever.boss.plugin.api.Plugin. + * + * Note: Manual classfile parser supports direct implementations of + * ai.rever.boss.plugin.api.Plugin when classloading is unavailable. + */ + internal fun checkDirectInterfaceImplementation(classBytes: ByteArray): Boolean { + if (classBytes.size < 10) return false + return try { + val buffer = ByteBuffer.wrap(classBytes) + if (buffer.int != 0xCAFEBABE.toInt()) return false + buffer.short // minor + buffer.short // major + val cpCount = buffer.short.toInt() and 0xFFFF + val strings = arrayOfNulls(cpCount) + val classRefs = IntArray(cpCount) + + var i = 1 + while (i < cpCount) { + when (buffer.get().toInt() and 0xFF) { + 1 -> { // Utf8 + val length = buffer.short.toInt() and 0xFFFF + val bytes = ByteArray(length) + buffer.get(bytes) + strings[i] = String(bytes, Charsets.UTF_8) + } + + 7 -> { // Class + classRefs[i] = buffer.short.toInt() and 0xFFFF + } + + 8 -> { // String + buffer.short + } + + 3, 4 -> { // Integer, Float + buffer.int + } + + 5, 6 -> { // Long, Double (takes 2 CP slots) + buffer.long + i++ + } + + 9, 10, 11 -> { // Fieldref, Methodref, InterfaceMethodref + buffer.short + buffer.short + } + + 12 -> { // NameAndType + buffer.short + buffer.short + } + + 15 -> { // MethodHandle + buffer.get() + buffer.short + } + + 16 -> { // MethodType + buffer.short + } + + 17, 18 -> { // Dynamic, InvokeDynamic + buffer.short + buffer.short + } + + 19, 20 -> { // Module, Package + buffer.short + } + + else -> { + return false + } + } + i++ + } + + buffer.short // access flags + buffer.short // this class + buffer.short // super class + val interfacesCount = buffer.short.toInt() and 0xFFFF + repeat(interfacesCount) { + val ifaceRef = buffer.short.toInt() and 0xFFFF + val nameIndex = classRefs.getOrNull(ifaceRef) ?: 0 + val ifaceName = strings.getOrNull(nameIndex) + if (ifaceName == "ai/rever/boss/plugin/api/Plugin") { + return true + } + } + false + } catch (_: BufferUnderflowException) { + false + } catch (_: IndexOutOfBoundsException) { + false + } catch (_: Exception) { + false + } + } + + private fun parseManifest( + jsonContent: String, + checks: MutableList, + ): PluginManifest? = + try { + val manifest = launchpadJson.decodeFromString(jsonContent) + checks += + ValidationCheck( + name = "manifest-json-valid", + passed = true, + message = "plugin.json parsed successfully", + ) + manifest + } catch (e: Exception) { + checks += + ValidationCheck( + name = "manifest-json-valid", + passed = false, + message = "Malformed plugin.json: ${e.message ?: "invalid JSON"}", + ) + null + } + + private fun validateManifestFields( + manifest: PluginManifest, + checks: MutableList, + rawManifestJson: String? = null, + ) { + // ID format (supports reverse-domain dotted IDs e.g. com.example.tool matching PluginManifestReader) + val idValid = ID_REGEX.matches(manifest.pluginId) + checks += + ValidationCheck( + name = "id-format", + passed = idValid, + message = + if (idValid) { + "Plugin ID '${manifest.pluginId}' follows reverse domain notation" + } else { + "Plugin ID '${manifest.pluginId}' must follow reverse domain notation " + + "with at least one dot (e.g. com.example.my-tools)" + }, + ) + + // Version SemVer format + val versionValid = SemVerValidator.isValid(manifest.version) + checks += + ValidationCheck( + name = "version-format", + passed = versionValid, + message = + if (versionValid) { + "Plugin version '${manifest.version}' is valid SemVer" + } else { + "Plugin version '${manifest.version}' must follow SemVer (e.g. 1.0.0)" + }, + ) + + // apiVersion compatibility + val minApiValid = + SemVerValidator.isValid(manifest.apiVersion) && + SemVerValidator.isCompatible(manifest.apiVersion, HostMeta.CURRENT_API_VERSION) + checks += + ValidationCheck( + name = "min-api-version", + passed = minApiValid, + message = + if (minApiValid) { + "apiVersion '${manifest.apiVersion}' is compatible " + + "(host: ${HostMeta.CURRENT_API_VERSION})" + } else { + "apiVersion '${manifest.apiVersion}' is incompatible or exceeds " + + "host API version '${HostMeta.CURRENT_API_VERSION}'" + }, + ) + + // mainClass format + val entrypointValid = CLASS_NAME_REGEX.matches(manifest.mainClass) + checks += + ValidationCheck( + name = "entrypoint-class", + passed = entrypointValid, + message = + if (entrypointValid) { + "Entrypoint class '${manifest.mainClass}' is a valid fully-qualified class name" + } else { + "Entrypoint class '${manifest.mainClass}' must be a valid fully-qualified class name" + }, + ) + + // Permissions registry check: permissions correspond to host RBAC permissions + // (e.g. plugins.create, secret.read) + val permissionsToCheck = manifest.requiredPermissions.ifEmpty { manifest.permissions } + val invalidPermissions = permissionsToCheck.filter { !HostMeta.isValidPermissionIdentifier(it) } + val permissionsValid = invalidPermissions.isEmpty() + checks += + ValidationCheck( + name = "permissions", + passed = permissionsValid, + message = + when { + !permissionsValid -> { + "Invalid permission format: ${invalidPermissions.joinToString(", ")}. " + + "Permissions must follow RBAC identifier format (e.g. 'plugins.create', 'secret.read')" + } + + permissionsToCheck.isEmpty() -> { + "Declared permissions list is empty (accessible to all authenticated users)" + } + + else -> { + "All declared permissions (${permissionsToCheck.size}) follow valid RBAC identifier format" + } + }, + ) + + // MCP tools check + val invalidTools = + manifest.mcpTools.filter { tool -> + !MCP_TOOL_REGEX.matches(tool.name) || tool.description.isBlank() + } + val toolsValid = invalidTools.isEmpty() + checks += + ValidationCheck( + name = "mcp-tools", + passed = toolsValid, + message = + if (toolsValid) { + if (manifest.mcpTools.isEmpty()) { + "No MCP tools declared (optional)" + } else { + "All ${manifest.mcpTools.size} MCP tool declarations are valid" + } + } else { + "Invalid MCP tool declarations: ${invalidTools.map { it.name }}" + }, + ) + + // Host loader manifest contract check + var hostValid = true + var hostMsg = "Manifest conforms to host PluginManifestReader contract" + try { + val hostManifest = + PluginManifestReader.parseManifest( + rawManifestJson ?: launchpadJson.encodeToString(manifest), + ) + PluginManifestReader.validateManifest(hostManifest) + } catch (e: Exception) { + hostValid = false + hostMsg = "Host manifest validation failed: ${e.message ?: "invalid"}" + } + checks += + ValidationCheck( + name = "host-manifest-contract", + passed = hostValid, + message = hostMsg, + ) + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt index 7aa6916707..3b3654bd9e 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt @@ -41,7 +41,7 @@ object CliBootstrap { */ fun isHeadlessCli(args: Array): Boolean { val firstNonFlag = args.firstOrNull { !it.startsWith("-") }?.lowercase() - return firstNonFlag in setOf("status", "doctor", "mcp", "completion") || + return firstNonFlag in setOf("status", "doctor", "mcp", "completion", "plugin") || (args.isNotEmpty() && args.all { it in setOf("-h", "--help") }) } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt index e983cc75ea..224f5557c1 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt @@ -37,7 +37,7 @@ internal object OsOpenArguments { * double-open, which is easy to miss and hard to attribute. */ internal val CLI_SUBCOMMANDS = - setOf("url", "workspace", "file", "folder", "terminal", "status", "doctor", "mcp", "completion") + setOf("url", "workspace", "file", "folder", "terminal", "status", "doctor", "mcp", "completion", "plugin") /** What a path on disk is, for deciding which deep link to build. */ internal enum class OpenTargetKind { FILE, DIRECTORY, ABSENT } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt index 02d83d7d5a..2838a5b80d 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt @@ -19,6 +19,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import java.io.InputStream +import java.net.ConnectException import java.net.InetAddress import java.net.InetSocketAddress import java.net.StandardProtocolFamily @@ -73,6 +74,29 @@ internal const val VERB_MCP_LIST = "MCP_LIST" /** Asks the running instance to invoke an MCP tool. */ internal const val VERB_MCP_INVOKE = "MCP_INVOKE" +/** Asks the running instance to reload a plugin in development mode. */ +internal const val VERB_PLUGIN_DEV_RELOAD = "PLUGIN_DEV_RELOAD" + +sealed interface ReloadResult { + data object Success : ReloadResult + + data class HostOffline( + val message: String = "BossConsole is not running", + ) : ReloadResult + + /** + * The host is running but did not confirm the reload within the client budget. The reload + * may still be in progress on the host; the staged JAR is on disk for the next launch. + */ + data class TimedOut( + val message: String, + ) : ReloadResult + + data class Failed( + val reason: String, + ) : ReloadResult +} + internal const val RESPONSE_OK = "OK" internal const val RESPONSE_PONG = "PONG" internal const val RESPONSE_REJECTED = "REJECTED" @@ -99,6 +123,11 @@ private const val MCP_INVOKE_TIMEOUT_MS = 60000L // still close a genuinely wedged connection rather than race it. private const val OPEN_ACTION_TIMEOUT_MS = 5000L +// A dev reload on the host unloads the running instance and installs fresh bytes. +// This is the client budget: above anything a healthy reload spends, below the +// 60s server connection budget the verb gets from isLongerBudgetCandidate. +internal const val PLUGIN_DEV_RELOAD_TIMEOUT_MS = 45_000L + /** * Ceiling on a single request. Bounds what one caller can make the app buffer, * sized to accommodate Base64-encoded tool arguments and payloads. @@ -109,8 +138,8 @@ internal const val MAX_REQUEST_BYTES = 1024 * 1024 internal const val MAX_ARGUMENT_BYTES = 768 * 1024 - 1024 internal const val MAX_TOOL_NAME_LENGTH = 256 -/** A response is one short word; nothing legitimate approaches this. */ -private const val MAX_RESPONSE_BYTES = 256 +/** A response ceiling expanded to 1024 bytes to support diagnostic dev reload error payloads. */ +internal const val MAX_RESPONSE_BYTES = 1024 /** Ceiling on data responses (status, MCP tool list, Base64 tool invocation output). */ private const val MAX_DATA_RESPONSE_BYTES = 4 * 1024 * 1024 @@ -220,8 +249,11 @@ internal data class SingleInstanceRequest( * * The line is ` ...` */ +@Suppress("ReturnCount") internal fun parseRequestLine(line: String): SingleInstanceRequest? { - val parts = line.trim().split(' ', limit = 5) + val trimmed = line.trim() + val parts = trimmed.split(' ', limit = 5) + if (parts.size < 3 || parts[0] != PROTOCOL_VERSION) return null val token = parts[1] @@ -246,6 +278,15 @@ internal fun parseRequestLine(line: String): SingleInstanceRequest? { parseMcpInvokeRequest(token, parts) } + VERB_PLUGIN_DEV_RELOAD -> { + val pluginId = parts.getOrNull(3).orEmpty() + if (validPluginDevId(pluginId)) { + SingleInstanceRequest(token, VERB_PLUGIN_DEV_RELOAD, DeepLinkOrigin.OPERATOR_CLI, null, pluginId) + } else { + null + } + } + else -> { null } @@ -255,6 +296,16 @@ internal fun parseRequestLine(line: String): SingleInstanceRequest? { private fun validMcpToolName(toolName: String): Boolean = toolName.length in 1..MAX_TOOL_NAME_LENGTH && toolName.none { it.isWhitespace() || it.isISOControl() } +/** + * A plugin id crosses the wire as a bare token and the host joins it straight onto + * the dev staging root, so it must never carry a path separator or traverse out of it. + * Whitespace cannot survive the wire format anyway; the separators and `..` can. + */ +private fun validPluginDevId(pluginId: String): Boolean = + pluginId.length in 1..MAX_TOOL_NAME_LENGTH && + pluginId.none { it.isWhitespace() || it.isISOControl() || it == '/' || it == '\\' } && + pluginId != ".." && pluginId != "." + private fun parseMcpInvokeRequest( token: String, parts: List, @@ -819,6 +870,43 @@ private fun buildMcpInvokeResponse( } } +private fun buildPluginDevReloadResponse( + pluginId: String, + handler: ((String) -> Boolean)?, +): String { + if (pluginId.isBlank()) { + return "RELOAD_FAILED Missing pluginId parameter" + } + val result = + runCatching { + checkNotNull(handler) { "Development plugin reload is not available in this host" }.invoke(pluginId) + } + return result.fold( + onSuccess = { success -> + if (success) { + "RELOAD_OK $pluginId" + } else { + "RELOAD_FAILED Failed to reload plugin $pluginId" + } + }, + onFailure = { error -> + val rootCause = + generateSequence(error) { it.cause?.takeIf { cause -> cause !== it } } + .take(15) + .last() + val errorName = rootCause::class.simpleName ?: "Error" + val errorDetail = rootCause.message ?: error.message ?: "No detailed cause" + val sanitizedMessage = + "$errorName: $errorDetail" + .replace(Regex("[\\r\\n]+"), " ") + .take(400) + val recoveryWarning = + if (error.suppressedExceptions.isNotEmpty()) "Rollback failed; check the host log. " else "" + "RELOAD_FAILED $recoveryWarning$sanitizedMessage" + }, + ) +} + internal fun encodeMcpTools(tools: List): String = JsonArray( tools.map { registeredTool -> @@ -959,6 +1047,9 @@ object SingleInstanceManager { /** Test seam / host hook for MCP tool invocation response. */ internal var mcpInvokeHandlerOverride: (suspend (String, String) -> McpToolResult)? = null + /** Test seam / host hook for dev plugin reload response. */ + internal var pluginReloadHandlerOverride: ((String) -> Boolean)? = null + @Volatile private var isListening: Boolean = false @@ -1102,7 +1193,11 @@ object SingleInstanceManager { private val isLongerBudgetCandidate: (SingleInstanceRequest) -> Boolean get() = { request -> - (request.verb == VERB_LLM_TOKEN || request.verb == VERB_MCP_INVOKE) && presentsLiveToken(request) + ( + request.verb == VERB_LLM_TOKEN || + request.verb == VERB_MCP_INVOKE || + request.verb == VERB_PLUGIN_DEV_RELOAD + ) && presentsLiveToken(request) } /** @@ -1121,6 +1216,7 @@ object SingleInstanceManager { * back. A request that does not present the live token is refused here, * before its contents mean anything. */ + @Suppress("TooGenericExceptionCaught", "CyclomaticComplexMethod") private fun responseFor(request: SingleInstanceRequest?): String { if (request == null || !presentsLiveToken(request)) { logger.warn(LogCategory.SYSTEM, "Refused a malformed single-instance request or missing channel token") @@ -1169,6 +1265,10 @@ object SingleInstanceManager { buildMcpInvokeResponse(toolName, argsJson, mcpInvokeHandlerOverride) } + request.verb == VERB_PLUGIN_DEV_RELOAD -> { + buildPluginDevReloadResponse(request.toolName.orEmpty(), pluginReloadHandlerOverride) + } + else -> { logger.warn(LogCategory.SYSTEM, "Refused a single-instance request BOSS does not serve") RESPONSE_REJECTED @@ -1407,6 +1507,69 @@ object SingleInstanceManager { return response == RESPONSE_OK } + /** + * Dispatches dev reload signal for [pluginId] to the running BossConsole instance + * with synchronous request-response verification and timeout handling. + * + * A response that never arrives is split into its two causes: nothing answering the + * channel means the host is offline, while a host that answers a probe but not the + * reload is busy, and the staged JAR is still picked up at the next launch. + */ + @Suppress("ReturnCount") + fun reloadDevPlugin( + pluginId: String, + timeoutMs: Int = PLUGIN_DEV_RELOAD_TIMEOUT_MS.toInt(), + ): ReloadResult { + if (!validPluginDevId(pluginId)) { + return ReloadResult.Failed("Invalid plugin id for dev reload: '$pluginId'") + } + val target = + SingleInstanceFiles.read() + ?: return ReloadResult.HostOffline("BossConsole is not running.") + val message = "$PROTOCOL_VERSION ${target.token} $VERB_PLUGIN_DEV_RELOAD $pluginId" + return try { + val response = + SingleInstanceWire.exchange( + target, + message, + timeoutMs = timeoutMs.toLong(), + maxResponseBytes = MAX_RESPONSE_BYTES, + ) ?: return if (SingleInstanceWire.respondsToPing(target)) { + ReloadResult.TimedOut( + "BossConsole is running but did not confirm the reload within $timeoutMs ms; " + + "it may still be reloading", + ) + } else { + ReloadResult.HostOffline("BossConsole is offline or the channel stopped answering") + } + + when { + response == "RELOAD_OK $pluginId" -> { + ReloadResult.Success + } + + response.startsWith("RELOAD_FAILED") -> { + ReloadResult.Failed(response.removePrefix("RELOAD_FAILED").trim()) + } + + else -> { + ReloadResult.Failed("Malformed IPC response: $response") + } + } + } catch ( + @Suppress("SwallowedException") e: ConnectException, + ) { + logger.debug( + LogCategory.SYSTEM, + "IPC host connection refused; host is offline", + mapOf("pluginId" to pluginId), + ) + ReloadResult.HostOffline("BossConsole is offline (connection refused)") + } catch (e: Exception) { + ReloadResult.Failed("IPC communication error: ${e.message}") + } + } + /** * Start listening for URLs from new instances. * @@ -1431,6 +1594,7 @@ object SingleInstanceManager { statusProviderOverride = null mcpListProviderOverride = null mcpInvokeHandlerOverride = null + pluginReloadHandlerOverride = null try { serverChannel?.close() diff --git a/composeApp/src/desktopMain/resources/launcher/gradle-wrapper.jar b/composeApp/src/desktopMain/resources/launcher/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..eddabd2eef8d94a5437d6168ff9c87a78ff725b3 GIT binary patch literal 47505 zcma%jV|XRZx@BzJPRF)w+qP|W2RrDP9UC1d9oxo^)3I%LIQh<*=g!Qz_k45q^VI&e z|5VkgwQ8;Rt*tBv4uJsz0|NsB0z&#Z{?7*m1QtX=LS2MGMp2SUUPeqpQB6Wa9TEie zub-^z>bb3QVg*ju^jKS3o#9H#w4Yxz1*n>pYH+2nC3dC@ic(OUh@sI7>n^@O3t+EN zk19TR2&69-M23X8{h9JYx|8)kwwf7ttr>teD4+VN#nkbK$s(IG`^odY38j0~G5LYI zE8yj!-3t3WJpbc*GP8f1Ijv!GZFxNt(Cq4DxZU@%dVh&ur@bEGnl2N^*QNXDgt%%uIzL8-|f9*0a_P$gWq1W= zyYFqsd}OSk24kb~1dN}B%z?^{HGmwKoogz&O?>^nlNT;9zKwXe(^*#}CA6|3JU~$) z84gW6*^!J(I2cJ6Fex`F@*8Z;s#mTo^y2AJ6hSf>Ei3lYhvt>4{wrqH*`8wlt+NqV zs$Y#ZDUzSWF!beISEBi0Dvx#amw4A=5p>tM)l(wMg*GU=hp|-Z=aZM_pw^OegdgFE z#1Jtd_&p~_;Lb@JjM5MZdJErBWf7~hq^IxX89(}?*<2v)uDSTyr#g{7fM4R;@KjPU zef+&aPhcAskT5|z_09<(`3G^SKwJ0e=Q(TjU}<2E7jh(ZoiwT{!}jl%GU(rNo2?a! zx2+TFX}Pt%EZ7ohNMI$bpk|IVcQ3Z2tWLJSZtq)*Im<#WBDYEfci;r(!~8KiUAI2I z+)9QJ;|lF-J*nrrVRIf{Rt}tBY`7YBW#R+!Qox8y99}8lf<@)9znd`>8Q;dY znEDDc?e6`E=jWxW%O= z*dn!&=MGIsRTcKy#$f?nc7NBdssxcHDt6p!g8h@bt@_P63RGK`SeA81RG5nyyn|pn zh5?evjH@qk|v=A$Ff0_lB8|p@3G{6%UYG`sujf7mV;X1<41iQ?RY8pV7 z+JTVijVDHlCoGIu&$AT+dB^5QPcKo%0%E$4uIC6MXfn^S5s%Or=V!~H;WD2>O)~K>|X>YMP=}Y_0pejZk-6r@uWi|+^N62^lykrsvI-LZ#)+m^*{7pxuE$-YJPa6ES98M;^-kOi6XZp$Mun zVh_^?Hp<}gH$rrm9(0RoI9SWRQ6R)wVQt0P3)HH@+_$;Wu?Pdh#`*-jv&l=#aB#ZB z__a1vF1``N!=i=c>_*5tSi+du{D=L>p#AE6M9%CROw=u892xWbhBJ2&ZWOPUu9e_t z`J0llKMY7mQOc(WraFZmW=wk^KbcDk)u1}fF!vO9a$)!UcLP)4H1`%4xgRqS0K?Ri z5wDR#A&14*d%ZEfJ%yaM!xA9$SjkFRTM(E=VBF=fl`Xebo{4H-4hj0}f`xQV%Siw~ zm)X(4E#M~0rjvozMFh8$OtrMtNIwdWI#K9mA^S9Y`%(O7+DH&z2BPw}+FP|N{8`yc ztMq*2M?9lMzlQKSXTlP7_S}q6O5>aSLKTkPfx$(5-5iMGcgSoF6$&wzunij_p=r=9 zULJ3>$)nnNCaOIhR<^3ydE|tmD2_eJi2rKJ>=4lfdXl%T^<`2cL8Qnr#g}u6)mqEfkdy^j(pd_;1LfQq)~T z)#*RRvAV3a;5g%FsE=#2$4c)4WyUl~Bx{f3L=Y&s6_!#gFQs!SM z%Ptu1IMS7C?+LldgwXHRxHrmZ7c|W9txqXT!D^j9u-AN|Y|OWq2SC{L<-cTTicAmi z_r#W74+DHIHg*akRkcJKQULezAc{~%>2%5wLQ>VNv3usWH7RnZ2Gz-YT0A%><>0c`H5JO8&DXi*zR64@Cim$sxd2bU<1bGfQN zYN$wwe1Suk{w@!&Grd0uH@kI*wheyqH}Pu37`unlXJ3eVY_&RLtw?MtwCC}kX& z2r=ymZ+8nA9_W&_-!Uk78%AX;0kBIr^@FWC=Iq?}st>4E&+p_%duBh3kVNp=krEPD z)GOWz8oLGhf-icgv}Z?)m7f&8FU^%9YU6rK!9w3vM<_rm+D;$*BFzlm^yg?%23uAQ z%KeUiUgps!x2o$8_73aGGei+l?ijb$qk0&_pcxE$L&m{m1E)z5{%6fgW`S-VGaRav z!S?Iw_l z8bcI?Ymm-FZKn!Np;!~O96H)0IY%e2ReRm7kG<82Fn{mNbwWMVA4 z>uZy@Ye%>7g;Xba{0<$EH@{`|xhnAW31=;CMC_|9j?M+?>Ej*_aqKS9>ogRu%(R<^ z8J;b1?=_I671Vk@wUgy9Y-KNgni)d}*j0y<^urrM2Uk2lFt7uFt`+!g{6?nxn8HDA z-|mcYugdaGsE%N=JvnV*xpYv3#ROT8=BsCVx@0{J239XjS;u0Ma+!u+Fwr5ij=6m0 zLSvIxxB1C7^gHZ#DcL&5(^lO35rh)6% zTsaD~2LM9BOvklgrH#EyzGJ%@S_@lewSL>+u5R+Tiq+s>wC&&!bZ{TdFdO)hkb5-6 z$JW2#Z|Z!%lkE+Ji(AJ*TFz!!5aIfBcEyHaG53g88ae_isos&=hRdKu{(IgmZ3Gds z7k(3>R}TbXV~wbz&J~3lCtMn+1npudNl-F=qB2KmbKczrin|qq(zUiV=mz!5jQt(W z4osJngz2I~I*eB?O3AP2V$NNllivTjjiDCk>V%*qVl&IrYG0a8ch#hengcSQ0H~+K zBrZ5)DU<3ZAI!Gpd$pCpi>TAd%xh;}9a74VXzmbM7C9K#VsIv!z}_@E{+d_U`?Nq% zi@u}DiWhyB4y$-r=+xk@;E9jM)7*`fPg)%mEu3MTd`DT51r_)uS|F(! zH_LOl6m)}??`_oHJ&>D)Os*^s<836X+kL$G%25M*fJ%&Z1B&T zx8I#PIpI+RmNaLK`Fp&CnIwK8BSBAd1%72kS{KygNw=~bG)!%CA<;1+2uK$d2#E5( z^@|w)w_j8cQIwICP*Z1Ako+&t$S^qx7s8AJvE@f{8IQdTeE6YPrV5cF8dS5|VoNd< zANp`#(YR!CkO|d!PGixcChz$md$u3@%N8#7%MI==THk`Dlx!B6XY2sEDGUZrd)9ZV z`Neo82#w}>2PYcZeDI8fty)z?U1X_n8XA^i5hNk;Ku&STgIxXgtP~=n*gS)-O^6j7 z-R8FH?Zu`x8u%&h7qGwPXFENvoAPODTR+FYpC8NT{G42^n5yv;0}-EEv48O`iX+}!?a@(PLya{a<6*$#GkW&+gS*DX|y z3T|bC+7j^vr8&A+T^EY8jqRDWGEpR0uQE9h$nPLQ$=sk4R$IL5iqjzJwhACddroj?Br~lT+S2>vo<5ZJwlL{#iW=$)A*+<(iFSGBZie!dF^3DNh z?&VkWO=0E?+KTHCe{4{tuuxRaV9(2n@)ICSnZ2(;4v}b^r=)pTAhI4=3C5^0CHG3> z5h}3Rg{iTfU#*m;NN8>F%TAm=@&ZkrpGX$TSo?}+I$VpJo~E7Htc`3-$LXM;rG9lE z72K^9V-I@?9QApE5W?Uzl-x0%^3DO@2O@e?1gXf|5|#& zaPJKC&qP7%bNu_I|MIs>uk=5yw}q;n61oV+J0O+OAx&;v;wres&^q5jLs*uj3x$aS zGMW-4nrUu5pKw|3SGz<^!a(je)0GZ68as>N3*RexSA-RI0-B-a6wht;CExAj>+9`3 z-&b6E=8n}>KTY4lg_b&U0yR3jp;S#E!jhxxcj#Giw&7vWgRckGs5^*u)}A0>LkQ!I{?^dBG~R6ZVjsS)_$H=G&-0-z@Z^T z;gk9U-en=IA!lcEox4>qLD#kR4LdF1skHspDSpcDCtJ+ybScF*(D?T!p(2YlA*vwq zAJ4-kR{A+sw33EEiS2Z`n_qo}aC3;lJcfq<;{niS?5-}rPRGD7m$_b3hl5hZ5!aN! zPLy%q0TY}4dDKRy07;H;-8fl_tf4Q;8~MGZk_=Na8%JZtZH-%UH;0oEvTv6|jv9#5 zX8r@RdYCzRycx0WuBIz*2gC5y z)@!vL!f>yhNfq-Oz{~es!{ua*4Ca!K4t`s7c z?iQ~9gtptia7l`q!C%-Gm`i0ekj*E1s%i;tDz+ew*Y5eDj+TqZT=3(@w4{BmzLsxw z!coPP;`-z1E39lmq)-pBMaMZ-6|l&KD!tY3aLsLct@ZYHshJprsG#TS`shgFPp8V^ zVpn`qovk)vp|y6`lB+%uZx_8!7sE(RD4jQnwOblArJa`ci^wW`^a7L@xC*=OWa6+M zWodt%>31m$zsTBhf2>XGc19kgP`HQ`g8jk$!D5TuerlYMuKnf|${e0*W9mQUHk_Ev z1}3`IX4Nk_!^H+3Mcz{yB=h>ksBn$HPmbW(DR831#0o6v_VR)8<~YCJMRB4}c!C+% zE(&$bqy;^T&;?C?Oe2OLHskKJzIx*E&hoNHm$F2u!;$|m{&DwYVi1pqDLHKXV@l)k z36#r#G4nvPjNrHa_$71n%MJ0`6v*1wky|(>O$xHJ3V1>n3+s8hR;IV+8_`;T4HS#? zDo_Bx04bO z85- z>;Zba zyPAjT{}#u8!EU35gBrRPMj#^z{$d`Qa77h|qZ+tOsx>Oi09Oxo`F3$}U#JV9^|yXv z%A}*E7r7^|M~P73<_q|I9kZF`ywlWE;ry@m88DGWrtFD}gPfNvw_Lvqx2b@~_kB8$ zv}^c&Bc+_zj2AH)7YDT;78bHIoXODzI*o0P&RWg#jg~2pza30qE?_b$U8NSvMOWSN zIHb~7wgBX;vYiEs-UbVuI7t>P33PF2i&FtNo7Ol`xJ0n4`DNxKG0`#6u{1%FJve(7 z6()8&O^z@Cm+@+II!-2hvI<;Z&&BeE79GZ;678KP^0R^Hc#^~cNY23 zXU4@&2L&gWb_*TPRyqoIHurXobs2qgWjGU)o6xR;%r?K6?I~q$f1y6EG_UQOemWI# z;9LlKge2*183ODujxR$J&WdACrinw@RlLxSPDp0TS-stiKl>_9aEFlWBz z4o))x#Y{SoFc;HF37qwrWdsFTPqL2&u$$VQ%699A5}Gdrv*zqU&NrNW!e4V($Q{Eb z@C3EVde^8R$2|_zL1pX@TNlTcLk>Go%~+9F$r95adgKnGo+d#?3nbB8W3H@vIViDl zNdLBOVr-}K8UauAi=yA$+Vt`1QsscTU*%lr74*hlOAW)u+*ewJHiY{qRFpgv-n!X6 z_;zwk`r8TBo5iM0`)nEPT<5(udKJd|fU~VoZ#uv+S%6UAl6zHFp(6!iA&>i7nBdwR zVizF<+MXpZIf(@zQ?6-PqZUpc89rh>{hU0^U+qm=&FW4eMb^^A)OYC1N8i_gZ2}ej=NJx2ZmfOOT*an@)`UG zxzAZ?M}$(t(9tj7<>m>lztI~ccK3!lUGWUVI7hb(jhyn=Db4=)<98-}DD~I5R(Hl! zu=tcAAoSmzYk~jdT+2B+c{%=5ivB51YVIcP7XNavQ#5tFFc$FEsg9Lp)X1_y&>(5_ zm}QV7ql95XLL;J%DXim{al&MmWJ;wyH1ssGQJ^snbuK-`JJ)2kkxp(l7LMsH7%l^D zdcEGjpSO^m8N$PcF4Z-{ISCPcj;hrT{jGA}&YigL|4irl!)-zNk2v29MDpKk&YYc)@pPt9^quC8sB_uIJ0dnBf_RA0`WT0W5c1_q%Q*_If_syb&1MJXv$6n=m^YAi88`5kqcgxE zHT!&IrQGr=Hag$yPP;YB)|O^{4_bY7`(em%E`uHVCOB7!?WmkF4aKx2aAp$zOmB!p zZ{sfS^NgnElY1m^zXJ#n#{EG6N0;{ZkMZ|66Jeu}P2N)0nD{nw+2kXv4NQtveLTJP zq8xB*CfeD&C5mN)kXl^4Z4P?bvd6J>2%aY;7Z;Y^%^s1COcy6RISiB8O=1X*RSx0i z?4}wxXqs&73|pz8<9*uS7g#mPX20_4GZqpdnl>m(;*1X-$>OpyqLNDt!RgaVs*FjF zpEdmoBj7RsbXIlQ0&Fe$z?xT6@xUxtvMKb%c*>wkiL%Dj^2jlvA92ce&*EpInxGm; zhH7{Ec1Y!xC@645q356GIhKr&|SNuCtFCPPwT=qG!zQi zf8Kc;@8L|h@U0+?F9Q@wk3E%0Zb+P*6XKSd7*&vP`D)qZRWD5=J|3oAVIByluY?Qn zaTzl&XEuTzt=Ce4lfd5&wESsarOEY)V?`&_KC2l(j%u31)GCOuH1;Ey!5W?7Vj_Xt zS4OE7xrP9fv%yJ3Omk^Q3YCS6uXsHEbPwgAMwF8pEvx8E&(v)9uug;#Cz* ztJ87q7^qM(UJtO5ooD%#E%^OpbQdPeZPr+K$FX7iabuC5c347c59={zQ8Pe>{&Yu9TjzTlu^n?A=u%gzT@W990w?gVu|$m zb(Z2_!!&KO#AqU)nWBhA?z58zl?8E1*)_fFj&LVdsmEoS{}-HEK0Sk_U8K$ROo zS&f4BHBE!>^LlF4XqTaHz5IW!xN}gFbBh|v4AZXIDft4ciGxcpM@8SE_G z55YtvyO15!XNCpN<_;C{#Iq8G4&@}mG`yT6VdunGQVCu)$`v)bsaLu+KjYuUhhBT!VVve9>y&;aOKnTM6I zQAh0so87UGBP+SH2bI&$iytVb?!=+KN91was;F!4qK=`f&F&b|Hhs3ne?s#TT5~1M*dJ28dCgdFYel( zZrY0cdX2WeD8+cJyDiA^@2KQf7isPl5`DoEApxq~m(&%E7-A{hS&M$ot5)&h%5sxZH zFG{L0Hlk06rNVQwz+^BQ1Q&~W)Hss4(u%aMV(LR}xhAKSFIId1iF`r3W8z!MhgYDF zWX+MoA7WuOqlsBFj1^Ume5Y=-W5z#SmX)!~?wh{-G5-k5Pvg3GcD5w=P(j&|5Cxt3 zQ=7lm{+K-Rt*>7Q5%U_Gm9~E}Fd-}C6b|$H@jw~YN^vqUg?=a3^py$hBeAEZ3uR&5 z`iBIz?dc4?iGG0`(ytbziHhj+!#bJ1$lG`d{#)>B{y1jDz&^?6H038ESfDq=J0KJYh!xV`Y3P4+H&(E5bF*=@`lpJ1hDHCAgk~pQD$NPw z40kv8^2$=JVqga4V>Y}DMt_xO#v^^U4R(PdzjQozP+vKn^`sb*-uc*tmvR5nb%lHt z$12E>4JsB4l)I>2ntpuI|GX0~T@nj{(&vp`**INl?1pR{9K^<_c2#B)c9v)6to|Y- zTFI$w&7rhj$By0lmKV3mUzWbww+8#{n8)PRf*w)6ak{9#QSm!rSWJ$dqteIpZAf|Z zm=B5J3{HqdOV@gWVQP};g!q>+g6;U}ONqB5U-0&~L$6bVT)o(`%vb}XTm3Y-3LClW z#FuYZR))(W#^V=~Oe@=J-K%gu)ELps>PquO2$5v{@z^P{hJQ$FQ zA$l?64|d3fqh1D<`F~*mByhB0BB0Oo`EEMEe{eYQfkE!AnGki+tav2&1Uy+^%* zG}A7CItGc{VK9gMhRBrXA1140{mLRP3w$R_@CuHf%Y4HzsSAKR7WxaR_N#TtT%Rs3 z_-|d@e{|dX-w^dOakcpOx4kg6V?}fojCaP>hGOlpFA?yuc?|2y!eeAbXzX7aQLHKM zkz2D{8Nk`*4yG_jCDAsAiEXvf6#PMm$Gl4*E#!EU*7mb5{jEB?KVF|8jp5`Fa*>gj z=7<-_mOR6%D%{F7Rd>rZ>&gM628E_nl~Ih+jA1k_u z=u5{EPfMh2N)mLdwXvG-D^0$0FcOkVX;m0nrz7j<$Z+akz(G17T$c=`(evW)Hh!Q$ zarlMhAuTZhC)nIuRsn3hF5u4@e8`=~%YgQgE8baxjkSO~0~ApA=b2bNgeufaB5^Me zxIU4mtw;7wgmo+-YPd1AwisWQJE?j;|F}|l$22wkYWA}m|2u&YG#%H1Nb`yCRdX-Q z0^g-5rq~HhtKuB8_-9sa?US06o*q6n^q?4!PxO zu}BX6CH;PRi=sVfoqm_Y5asKUxNsbcqfXGgEf&o9)8~}2N-VF?167OQ2ok&=^k}2F zGuwwF+n-g1m*lilI2*MHnW9%|;~mv)d{k=h zp)ERiuitw}7QgW$4I}CqDS~O_p_wBO5h68aVzYH4HCx#Mh^N z22nRj9@@2gd;n|78D!eTtXQr_@BcQ;F3i$A_gksSU;rpphqy>tbuX{`@sHI1&hY0> z_vewJgZw*k=l)L&(*M^RDJ#fQ*2MWE-69f3CgR(HCNthHdih1BKh!BMRJL|>HxDzag_13XftbrL zFx*$+GE+!4g=`B;L9`s=Ze`uGbex#B2S+_z#g?Bx5e~Of>WecNxqnz}X^|UFSUxb& z$d(`Ti^wnj2sS&TK_J|B3mhxuZmi}$6;bG^o=(Z>)ck?G2D-)uKLog#lr;S z$`;Ds*e5gAmtAHxs_t;uSO@AmP0cLyOrJA$h)6X~P4FzVAt; z*c%;$MBvgjG~`{2tGzq*S1{zontJgj_F9pC`Q(f{pU@~1g!EDUMT|yoH}+ni#RZiH*5Cb7gc>ThK55;4FizECc1M}Y7 zIO2uDEXmcvPInW3nGZ`8pi{@7XKkDifh1TTv?--O{*mwEMQP9a3Yh_Yw{rQQTfOOP6oi8_C?I((#u)D zzCZp>l3~Qhx>fTjB40m!GE<@)dcH|jK-n4fH-c(Q5lKuKq<&99@We75bCH;nes8V% z&nYvMxdwK|4~YQZd^!qg6jxlRn#$VgTK;g|`^I2Q{qZf@YPRI}7$G9aG^pwL??VsvTkc{0 zqwO=^zs}{&g2mFZ`FOrrD=FijMlju^$+Zlqc^dGb>eJD}p4fl=OLGcPN^B1=PTY3)_ zim|pf{}oa7CeFgkAd#VjOi=Sg&F~KY7Xp{eK~r%)(WmpbH38QDglGOnk5v?uz&;tK z+#iPQ$>Xm6`YY5jI2vj+bWBbJ9t%;2hgWzb&_TwFltmIf2#pH;AN55S&lot^NCnDR)(w}I+N)Fq5zwHj< zRhHgfZr*OJd>^S-4BQ&hnIeW2@Ku31yy=g{A7NIMa=HpCQ^~`|$H`y%@^HYh3wuP| z`UxZFhcFtc3~7vAnnxU!8x(Q|k2dJ`sN9WfWjM2YqGvVpK&~+~^M5>{RZ>>o8%2tu z&E-%$v!m9-*FHi1wbRKjDWl&$xhCpwxkl(e*=Y?&yZ6z(==~h-wAFprs_&sJ5tp0rb{sw;v7C%E(eK7;od&0)DlN_~Xdm`-|Jy(7)Wqmk3 zXCr0Tv=_<%t)rK~{_BNeLdTbavc<{7{!>c2J#F>@(ZL^ux;i}lm+bbLV9=t^1G3*_ zeY*I$Y68!}&7>W?5r2L^Ol82q60or?*#j`JuQxSlOuMw$sWWJG?95`jK3BD0`Vz0- z`<7j?H=$k$Q=nRT&tqp%qg1GA!-ZqQLE^;b11&}l>?j~Bg>evf8%g8S-Tt-;5Q!e zq?RVbekTKnK%DO^+4+egr{1o@!TpdSjUyBDPjQ6rH>NhN+MW;f@3(6b21q6p@!^xl zO$B$zdn+astC3}b&xkB(; zeuI6w|9XMzOJVUKc=Rg$k`y@%Md!n^l$^fxCim(6yFDIX(i6yN%%-LcoPgg0&iRqy z#EQzkO9M|ct(EMUNby3__P=2;;2}vLkpBX=0fxG%)hDo9{?=jqeWm`N{Pi!|8K9x( zg|30|jwF-L4v|lT9U?Ic^QE&$1+J-KO_W;ICP@~aLpi#Xt#lMPD*q!LsL2TT4DF9j z6tF$m*zuKSO!w#)li(ltS3=##boOGcHchI-vp-YKkM9qHe($fB&1oR9+jIcv$4jG& zZuHE9lS<~sWnua3NRMIl3jG!OiDB&!~G>u-3t;Kx8_1xTR_8HrW!30g~OR0M@Ht4>i|X~psU;366z&XCQ64|PT^JwiqOMb#j;qn4m&QcY{aY)&hYvf zD|U3I8KT>p9I;^AqYlZuWD7gUa9d2Y-Lxij<}%pa?%5Dll|D1$9Lp<8-fBQCe0zv8 zun$=OO-#fN#Se%k3d5H<6B>Z`h(ZatK5~!;m5*F=P6x<`L?8$6v#QkOLM$TFCT(zCtaEF z=)g!t{K*P}r#+1ejMSAQN;oPq=~qi!dFW9!?6iC)mjJe=D!;sH=Ho80M)|bU5;qxo z<}(9A^-glAVl^neEeF~sr4T>_59 z#VlnluCJCE8>M61cRdZ0a#M5}$RG&QjV< z`cWhti_T3O>E*qw4KZ`L;ifnuw2Gap%keWP1eF9bw@YkVRjO@fd?^dAK^W~5+LpSp zLvZ?-HDa}B+35^dq3}CZr_z|oXe4nN2MX6BU4L!srpV1{M=)x!kHyGFsyV^wX%*5& zj%l1whJO)b&x!UE3iDP1;c26;UTDejL&INB+KhA2c_u_ABi|LOpJ`R^s|a$BJz+9H zdDPgMs*=8il|hd?aR=5yE@2g9{K&VQhmkHU@JJ%kWWHa~N!4RqD2++I^CfXu_5W7= zWTk3tCGMpkrLQ||A!_pX76azn6v}?&m zYQ-f_=UbI0KX#;!e4*5&u6Y4`Ry>`fcNUITi@8o*gJa5aeV6c`HvMty;0pyA@p^8!VNIkd?eT4z8 zrD%+4j?eWy&|Bxv6a&-d;v%xldoCgfYRqjCk}?BvH?AH8b!P@j57Qd_R#(Bdr(VhW zm};qlY*799f_mR`Nj|{5-5~v7R{8DiT7CkW-;i_3@L~m|c4&A{x14}s7ra02XJ)?C zzpdZjBwyb3HeFshSS|Gyg1=iW6JIY~ZJ0$w2>CYv-iNub*?5bu%@L2Ktlw9LbLijh z!O~yh5%x>T-bJ7KCH&RJJX!g_4{SK)86cCLHl8<* zbAOBC0Z4HJWh(d(g0{3%`xHHwt_Js#ii6sMiUyjtK?SBo|6nUbGvqJHrA0X-SUVYs z@-@*|t2%>gi_-aT0C*cn9>b+Qz3upmK1k{Ul=|MbXwGhz13tk2;#plrA_n+RONs#d zcZTJE;MsqWtNH)clJ+k=o1$T$g>QipXo#i_^DVVO*;-<@;U)pubXTFAK}q;>bQD>?zpq^&Ue|EEJ0M5QW-U|Vogtf zO!qm+e!IUU4uoKiE=4dB@Z%JEplBeo;%uo79TH1lP-ahNarMzia##SG@rZ4kbF~gx ze4mT6tH&I#yq*APjOgTFYv}y)W>20Ta&;9fi6YR#478BPB{OoXBP2E+n z3(*@bSTGA4mBAV_5E}6`Uv787C%(r+3VmTdF1&t@&mTSi z;O^Ba&{KmCbHkq{yCc=WL?r6AC3F2K5)y%d=IIMAEbX-Q_;TH2CW2DS=me;2R8_K#I;Xzzq@cu=| zh0fety|9RjuDp6b2*aRGT{cAr4Z-Tg0ZTop6hT1ZbTv*v#et}ST-Q*Fn{EV)yo4G( zV+g3hbC`0g0B-`H%Tf5%K^I2t&NNRPp0m@>`nLc|h#b5=h#aOX-I_bSbs=ctk;gt| z%p!@6gXs*CNlf4iqMpeVKMsV5*`e4wz%7j61` zci7{#@grsvJeVFnKUzsTcy)y1VP);2Ge|QtcOl%o2 z`;5@w*}C%tJN!cJb&HH*`H{-@JMbM#z^#Q;aNK0=LFveZeaa}Pf{@xWg+!__ClNLG zf4!uz_f%ieC>cz?La+)-i~kh={c$GpMkXp5+OrIU zDD@HaU132fuzGTT&236x&96I1unQ#1GYeAuK9Qo)CS z$Ao}+Qtk_mhWvN6a)O|-*VZvn3$I~y>cxhnNc7o(?bGPnuh~8#dVa-^TtZW!z=2?y zVl|HKM&2sV;XsKuVYv6YhCc$9DD!in30aDM8rNFbJE|o|NculXXE(U8R=+Z&tz%y*@vxc;%=?( zYT{|(>SkguW^G|+XW{xUn-!z6?)K?0KGw089ooX`{pG?asYBTv#Ho{S@==5fZA8H4 zjT_e-9g~VP*Dbu}R8cXyuadOF1+RxHcI0V2CH>v!u{Ztaao^#r*mK%#tjGAOO};}R-9`trXm}wK zb+kLrNB7I)NF%jg98;g>lgynW3wQwI5>v69Akzw&15f@Hp<}6(Op4%S|BX(r9Ezir zIZ~fiZFK${8u6h`CSUR0&jh(X1k6g3dHX&;qtc)*)Pj3< zyq;oDt&a&%KK;eVrInUIjUXB4v`)m-o?^Nos`Lm^WaNz*r=gFvzpWVUOAQh~LuXU` zQaoG;5nj5Eqpqg9NLD@r3els_(KG88TuXNT5G%b}LcSxKt}A;-RfR<=)^tkSJkoCl ztdfZ)gQVkiedHerQ$C0^?t?JRnet*>AHFkyspjqago(grubp*JS8jp5zvTjm zIm}`i&UQxljc1l)765=_10+O%uia8}i%FTnT22Pf%t@&v5?S~l3^=Pi59LZbNMR)?l z+zWl7Sk%pZPe|u-vLQ`3eaOP`-R1BxwTL$Hl0>L!;WjL-DHn4d43wU>ivV6gynYS+ z%wMrjscs64!w&|wXFR$FzK(UVuHD}r^{$8nNd|zEt>}Hzz`($Uc`RKfnZ~%Qx}si% zG2cGds0;DD9km@-02b#tylF7c;&kUPe{sfr4W4mS@P)C6D{@$d^?l_dt5B1qH7|F& z0ycnVqQBx!jr5BAM;eu#wm_KBg~_CYwM>8kVrI#ep6aH4|02z6@_e&I8Z_H-PJ4KE zSpT`0S1s%BoQtyjUmuK`UJa#}TbA_!)M)Nls&mpywWaZyB1-w)w}RGUO1mQg1ZE>M zhjIwbbu<#qTDXA&?=Reg%3^_6j&COAfM2(q?T7L!aBt6l@Zi+AN#-g)(q}j0bMH3` z$Mw{fJ)7U{ZccIa=_ibffGm~RrKGmCwk_;2EMuK$G=RkZHhh~`5rJeYbL9Y(ltXyl zaA5tr6NLtac6Rw@WnRpMJD7)kWEUoZmUZP}H_J;Sf&wDL752lz&#C$@)nBOeqCIJM z&0%LYWY#8JGdFfaxUL-%lh2TJSMlz&Lo74aXbAPB5S0s5-Tn7|PqhH0yXi8qP18v( z;rWdN>c@j1(7!AOyq@tX!l%XH{QT30e_s<3`G2+X|7Bf!Co{XxG6>V>a~FFLHyh8- zu3vi#5i>IjH#Y?nM-!|6#=#b!0X2pQO2A|w0zDriU4dc81S}`Lo3J~inP^0ga3K!= z{cuNH1~mqC7LvnV82yg;tGGdC>c_3-}fd5xjqE+cR@3 z0@+W?15d68AKVT2&6CgvpLMa~37Bb+;AG5---?ictL zTRtwRS=$f|3IMD4kgqkMJ+biB#|e@9lzG z#r@ba+vb8^yumT6UEp7R{)h9_1}pW+{S;!lzm2c|l&7i}-xsNYy&%QH{YvGn{Oy zPq=&X+myyA+I zAv=N2kQwUZt4_Uopz<7#*hGUQdSPnff=_|Dov$dHy(4Z^4!0vs7+7-~L(V>+O2rb^ z5wL~3-;oH!G-IC;as^a0h3+E|troGdgwDC0fUeF)&vYWtNhTMRAYrzqu)BWgzX{05 z{|$|kQTTllTTVBYrKNyj7_6)xPKcrsp$9$}nFs>>N=wrOkkZ5v7_llvpJcqciy)?i z*+u3ul|zSHUM99<>`~WTyyz1QZ-KVRDviZ-6eXKg9pmY>To&pv1bhK^FdQ)#>G|BwJ!eTd{hJT2-=pKbnbv-;k#;R z7^r3)5}6rCDM2Kh!g**LU15ynMg2NO@YPt;p|X?$i70BJu9QKRRmvJ|g(eLD~UeHUOQ?CUCU1e`G4CE@Dk4rdOZ?r3fXIq91eUdt^e6;A5DU&_+sM781%z1TKeEq3d#`tAgwtW;Ya`{b)Vz~!WyS2Smz~}9MA3v zR~!WPkSc>L7=7%t5Pv6v9B-+dS0kyIf!{&jm3Vx>y4*8Eiuh0H(o|a*%p%&`L_Mee z+(mwJNTSL_q+Rd&%*o=>zeJEI+*{TXC$e}Qa`GY`;X%=+HoPdSdyC>*Mb!k+mHjAU zB4X*fYD{aBYHwU{dWP758w6({F&Q7JK`@X%N9!o7F}iA#@OD#(1j2GS!@qLXMX5Z2 zEKm^X@IQ56Ju%?(X0FX!5oq5)RmajNiB*TM2p2uk9((z{U+XZfDndv>kVCrfke`~z z`&)MGF|h26q%dYmaq~Ec;6k-X=QKy4`db)VM$HvQuHILAq2cD@BxL%D@ zdsGU8a$%Pk_c}o8lDnXCMqR#*|S% z!ngc|i-5-$=Sq_Aj8sRg0XNGjjFVLhh(nnEYYicDz?Sp0z0T~#j2f-Y5N;P?#puBc z?$~bt(?8Pnaaz`KJ80cy!o?~D5pN@3Y(>%zpt$XKZSVTqRK-Zh}rK2;J$;5~P*VP4G6sd>hdfOz(NvIg^jyz=U`Y z4ekZ`{@|oP)`DN8^<&MHi8rC5t!pmr{ylHMVW?INQtbIA#aUFIH{Ld*f~Mnh-A@JO zkc$+xpK1}kDKpLF4Y+7DVC%>tD0`1>0ghhAeG`#8xqI-7x%-+HVmwR2J_iVCEhPPu zZ}Fi()c_KiK!@)Ti@)NYsF=iF<-@cu-|a>OeJbyII@cc5=0_ADx(SnZ|#Le@Wmu$BP8{IY4WV=p^mVEy}%M2GSB{D&!Rf-@q-#@)w6@|CzBF9 zSiQKDKvcKSlsl394^`g_A|P|rcJjx85)=<*^;=!OX8pXszN7scIiq9OQvtc$NPvVE zlGqFbuf9sJ(yTEuV=Xr|xEDdXg0Q8a{4bZ4@>Q%2Is-s60I9CsUCQ!@4uNxSQ)w6+ zwRrVzsz=DtrQZ4Ko?z7F&sW;;*wlL_Ph8HLuUB3>;KM+M1OI6#XB2sl@cTg;Apa3* z{2KuM??o2=X-2WOGcYC=HZe7Dv3CCNxxAyDnd1-sl(ukkHnIJG0BE$5j@^PPx-V?_ z{)p3h603X}ex;a30xL{a6AX+sKZc^DdMPN1NCt4}Q~3UJJ(FLP_(ELCrb9iIZQClX zTXa)OghbyG>1FN(-bKVE52z>`1G5zG^{B_z%;?wUaAN7NX@m6LOt0r-oI|zxEwudr>`mae)!t`LISBexT=#i^sNJJXqZwsS0RQYC+o?$*J{}5 z`&}fcR?FB1fotb4vKJi(E2skE8e9s+H74LPu>M*1ouJF8PBD79far!}&!r2wKNlJ(do0baXaXkWGtz zRJRU3>!<*O!uK>F8Dl2d27Lt*Hn<8zXxpG4z1i{=g`fP^T)x;}(Fn{%Xbg3rk52u! zStmvziNkKp%9>{4C{pK~A#n?NhQg!28Z^k!m-2Uc$`4&m(392_Lb@I+G^zF!uxZ^a z;06rkBdDl~A18}h6G9D6rZQ}A$}0DOlh1BD5!cLXA$*A z2AD)X_5CLxGVIy{A|?we@&lNZi`qV57sU`+M(H!~v3u9lKObr+-tIcp7-sB0WKJ|M zL}cZ#+w_dl`Bv7aY|-JbdzYr3rs|{Ha^}2HU)hB1 zlt3Gy6OT+*V|C@OpcAx7N*9Y0jZ0v~2a#klF>mf>K0n z&t;4p@ZM9C?BQ*g#L;^=KNPGOnxNo%V6YC)uVUd>Pv30Ot6_(QPz`z*qk?ES?SJUY z`yvD#$(!~PFcomP7jMHL{OSQDK;p?43YKqz&GyfR>Rl2x-y<$q~x>h6)0f7x)k)~|ABym(65 zt_S@XE)r4}ufCWksFaUiLlE-h+EkFZt1kfDxbdvUW3d!m%84P15q`?o{)ri{3NdH)wI z8?jYqaB#Z?;u4t@;sD-oJ$?B;v}U(xzeGq(oxwa5_kt7plPwn4295C%kc947KgC1E ztV2eAsRoxqJ&x4ud5($!zHBX8!%#E2;)tvoSGV1&QXKJ{Alj#JzcWYCasZt%v_**e zj!ghd78DbMeGb7EKNbj(FjPhGnmXR|*1VAcD2zj^Fea!&d7!9WnCEg-%DR!To!HPx>;TW-g7U1c1I1VI_f5@N5<5Si7UsSP0uP02gm*qy+I**O}9grWeucd^DpTstbRXRG>bZ<7<%*_*z`=?=(}WV z?kch13Hn3FEvTG75{&0d09ztoOhBkv$?*1%KSsnkLm>E1$VvXkkR$ldA@^?ps8}89 zx3Zbb7wqIkHy4)y-XIYWF$U`p^?Q^6AO?6miNR_eb`d#v(=l5(cGgmV_0V|JbqgO-DZETztMUT#I4z~ID7DpV3Vh6KD2%g4YMMvp?XA$&hs{-L&uf{7r7^Q z(&T1DQ15_{!exd^DiagYjwfsm%TXv$OW;a0_GK=oY2?MC1|p)CL992_mGxsnH;RfN&$K%VgeV>M9G4N$L<=r0YVe}eI~xhpgy8Cc zOwAes{p*g;q&Y1vTSJhUEmJ~nscIlvC{R_18mxM#r7rF5hMI`X^yDdljo(;6>d~3N z(M(*fny(6X*80)<4ig=p0@Q<30L(_!_{?5f{hSF6FkRC6Yv}<_j(sKi(T!I`5opc& zI=@;Ak~gL_8E2;(#{w?ZgQ;cey_Z+F7;}I*=Tt&rH%P&IiwdAOc(urCfY76`n5W1r zcVI1(>*wrp$=ecep$(C)ss=@cL3*Mhbu|sj#y!TSQ8$X;Tc+p1lUts2RRaCh=3AHt zY2S_EH#^+0T9jr5m-j7bHybKJ9q3$v_47i#JiL38Dc1xZJb|x>5;-2TWN7X+GGaC2 zu6Zw1rJl}7tOCLFYEXSQ@Py(2o?7FFk!)F$hlw(uUi{X-|0d#x9s*|5o#^F3^P>M9 z$c`+`gZZ$m28J-8nB5)H&!kCyz=_dLCJTFLGdhHDW+jDw_| zzT8z!+aJ0E90wG*`bTTe=w4T8HW zeVF!Q@(43&hmO`W&>cs&mMS(`t!ls=-tNJ*ckF)YJ+ei}%&#%@gF^r( zo(tQGSVkehy)x7<6aO#c_N^bL^!1GEWaB8;!=0Pr)DkBKdpdOkGz^ z7C~LDI9AbR#bd5n+jX7#9b&v7YeHjI7Y+dq(lt_(u2xmQ0-2X~qe*)HGuVwTx4;s~ zxa`sLZw=IkJmVIr$P3eIfPTv;f3k`8Eyps$T0}PGdC5a{c)Wp7pcfJEAPfqg6mia1 zeBKZ)lt+jDHGp-4;8=Fo{1apHztjEw-9dNl)*%DKtB}G(q`U~i2%~_|BYFrgP^pAySYkZ7C3y9Vp^NL~>jq;!-cE$St{(I({?Gpm)oH6q3}FBQ$( z;5$t287%sy&2PpYe;Wb3F?%_k-nPMXWB-!U| zsD}%yD0hhcr7^F0bxtak@K#NJ)}j`6phBT*P-+z$W^#Y0O+LSEl3koj z0Ap7-#_zRc7sGys6;yfRDvE4LBJAZwK-yPoL0pDpU+3#A4?|cx)2>)w( zA89QE{E1e9;UtyuciL)kC1s0@wJJ$;6{xO{?Hixw>t3c=G3{@C7P^PkbcCtT?|je? zz%Qk`lYUzuzP!O3^yV`=k2)==5x;#uz@L7&ModOVYb&#FBhni8xw)pp{G%r!Tvw;K z>>6Ntl1O*_^lK8R7^O*+3t@ThjKPwj>(DQYU*;c@v^eDI!G^x?E{h2SocpqIF)Qw#oP^5N3Yryx8Pa#q_9hZij1Dkm_EJxL5hQ;V>c+zG4*H8dYXq* zpEytZ?0u@8r!9(g=WE&6z%y@-Fq|2ksJTqt9yLyhAgo$->O!j7-%}Dp>ac31X7GXa zYI^d$W5`4s!?RUGvekTT^jNu6Cv`gOW;og18O^?}Be#aGczMeMF_g8x&_;cse{0wG1=-~tSRKgO=ct76udVWRm#_!U2&xljR94c8MlMpA52#~sSL#RZs1w~NiHE`fGJck1TU zE9!uCRj+G|H$!StbgaX6VC8c0xT*kPzVS7=(d8!u>xwogc>>K7JzLQBRyFoWvbEBq z2)&*9ngDFF>OCnAnhfbu?!CKS@i8R{wF6Kc5V-5pySN47SAQ*txc zD9&_)1!2p2$c%K$8T>keaJ1jr@MNJT^sK)b9vlBGV505^ENOc2W%ve8G&#uZY}N>Z z!f;HI&b__j%RBtaECh4x9ov}3a%Q!GlbST@Nn?-wO6Vrm6UFh?d}P33b+De!XM4P* zX_#x%(bH>TvdHHDhd9GX;rplBkblXoOB=QvWHlX_eiV4Jyt)|>xq=!g5`C}#kXpxN znKMe;Z?Y)4$R7Asw>!{b81!ktWSvxZ+?QT{Jn*%=RL$T!*s~n7+_LO&!4v9pOz#f+ zKC|mDfgRNxy{~d?|AJjr;toNS>Y%f-opf-4ct*xBGPwUoI`0O$U6=TJ=NaF!#2zRL zGjMpJ5A<2D(LcH`cGg563qfg7;j#;63;P8iKxswN0IC!73VuQ+a@>v#2&msZ+{*a2Zin%!j0lASc8Bzk+1)r zsDouA38LvlZBSXNbn%lmv=}+rq{7C34mX7?6f0a7e%P`s^amO!&mf8GNQHE~_OXhP%m$R3>J-gnfUT z!Jk)605keLTu(FSd}t}RV?54EfAbtQdCA!wjtx3c36(PB2{#W7nWF8kpd)rLjcR1{ zhHxRkSlZAJIWS-RyEcM9&?=a4cV!mV?3P6ArjWH#lW;I5U?l;GomOaSIf%QcJFF`7 zByCW8miaAP=7l~F^a1~Ms1}}pyNyf~Q&Ydy2c_+*R!bQWTo~RV3UaDhlw30>9Jw(P zV9#drjd|ss*5Vbw`U>7E+c%_U(RgjISNJ8X)Ph+zPt!tYna?gf7<{neajHG!*kOV$BWdc9pJcS|6^D3Tm^jYp+&rB(0ErnDqv7I-#1 z6^P|h^VwO(<1(-jd6RO9PCbrze_c`2^-G4AB6}@MtWkb;cK*6-(3VQRIr-9zAy6T7 z;^;>ZSAtkiqnk7J#$q4);DxG>SL5c&^#wCqE9RqYr;44c>$C1M#(4C0m}*7fAHQ*Z z+cw6qfCmhrng;Ja`g^yzd+NBHSx`yneXPMX#F{+yKHwV01EvJk%Xn6#zhCl8oHB|C zm}!ROZ-hR@C{u|!jMS>MR2n_ll)Id^$n?<|mfY&12MjCU!Jp;nuRI%g_;^YBgt?>WM;M}dvzhZ(qx!RahC{OL`6dOw2{)k*Xbgg= z^{=2vVfhRjw7-H@oi=EJ01KNf`AdTx`!?s zifxjbfFKyc|L*kx-N=LgxVut+=YD?QJQ9%P#0SE#< z9a!==`|p9NNf|~)Cbnt7A)IOzE3Hm$E2Yaysr4pW6^adrk_b&HAIWN;<+b58pT=sP zi-PKeob7I=F=Ph z`V3esaeWxQOQTnDAISDrrkGA{^CfFj*)o|4m~?w4_IY zBYVdh9jtO52)AIAq|dI?w^WCyy0!40#as@jVAy?t5R1BGbU+(7o*bec$QsKd&Jraz zXz-;mUx`9VB*9cljMid%PoKqZGnpLG4q$Q<8#oOrC~9$WItOl271jD3%y=}I7`sfF zA0R(z=2}N3tP!Q5v8Zjsr4Y_!Og)lroovfm1GMC!%Ct+oh&f0c21fCKpNkPn1Z zdU27d=Ruh+x^NZ!lVcTmKjTYUCZH|pZdVl>`ov|%uEmt)vnCA+RP)fjruM+AMzQH1 z$+kFr2r*;WEOnbRTNoxsU3OI296njLLfT7VsO53<0nUMkp0>5pp~if{6=ibhyW;ZO zT*)C7R*ag-hSoho43QQ7GB3C=#5HwJ7d|fg1Q*L{)F`{562zwc&!BcAX-m4)$D+3ha}ldYQ6mT75%;fYvxLS$V@F@8wa~3wUBzL~0cnSZ&T2!DU$` z#T6QIa$qqqlS-(95z0H{86PvLc2a%G$>gdyx|;H0(8jpYbA{{l~;m@Vcf0RKD0l}ku9DGL>RgrM}L;;zxvSXhkQNw&As`7h0 z<>XMk#l5jYmZsD7qCEx)xqW1jw+)1r?J)1&=RLX3jz%#VD(p>J20YxhyHM*MxUFat{F?n!b2wjwro& zwrpXxSRH!PG$aMt*4NzV4eu2NFuO2yre_iFjuQToRXOI1zs2+&XY}mmqSbMc z$Ohs&PAy9~=%Lviq61-bc|k!75w4($ceuVgTMlS{V|%Io{e^R^Lz6;w`J_bsiwtFn zIL(NI_v!g1P<|B;LTZbvCy38MbfS)`Y6IB}Vq47zvg^R<7VSf2B=?q7sj>%OA#v`w zB19AHYnl>fRPgL8dh>#*s!NsZE4($RW(&1vgEO7;n}&aD_f;&C#YBN(iJf4N?tnN`;ixQ3$pv)KE1W;Bp)j;xP6>74 z!k}E8R3m97A#Vgnf<0v=?}=-S!t-&EIeO-q(;T!i>YSOZv?@+EBODFHk5{|K2vVq@KUoxkjo+`?w64uX86w$(`&t>9iy&^Kf=R_*uy{C#Ne)J=fQdcjW8YGvg!NQeMcPc!#WiaTEUrwo zgfsrgJUOr{u?T6`bdmgFU=R+wDX4iV6>~e*EZz9w(NMNe046i%(Nl zT|1kPM|!>>J{F5XQ=*KPp2!-LghIT^tXiavpit!qd&}Sf&4@lIcz=4523GX480thY zeY%u%`fh2TRmOE0ygXyIR$V|QJ}hxLL+V`gy&ZIJh6Vi-`lfNBPGE-kEI$B(stk;^o_0_`? z47NlRk{KQp2}>XBfwBwTU%=Ho1TW+dZ9c5%7qh3^^_`ccdvh5IpRl0VaI#wZf#^F`h)cl_Pnuy zw!6M*BvxmV<_Ne4(Up)LOKF&=gIRurnz4zz5zPU?>qE`czOrT)Ie2Igz2Uq9W#srG zM;&>?Z8-2Q!C)yG!A&G{rN$jX#`hHg9_C$+GQrS)oVjkxjMxr|kh|{w(ASd|0u4{T zJEQruq|JGNXQ=%uPaho)D+wa*=&XZ6E1;a~#=hX5xL(-vY6&W=AZ=iE$bRSc$xpi~ zq{HN}Q*^4eW;uLIkba&`m8uV?=PD5H6qGdi`jjI<3t5Lm#4No{4z>*^WPO8CSX(TbuLe$U_YKEAG>?g)fsZ*B zbpnlL@NACLkVQ;3Z6TM>B^5$edib_k;C8g!$O!MWN)j(3qJCp}qr^!##m6yCJ_*|W z)+6PxIKy;1%e=Kbb`>{$|4yiT7k-o-Jz?St&SXKIv@s+~n!-SPbZWLt9F8l%K%5k=pV`YFF zb4Z0~CF=o)$>aOyWE;THs^uo)*ci!Ndmy9fy&9&bF z>XDhYhA7r_4z9fclE0abEorHGvvekJ9G~OiVXqHZ1QRhIwLb#pOx5>n>Du{o51G9b zT~>Ux7(V+}cWqN7|FxhZ6p8 zECYb`dV+;#(IjQ+m5J_JjD>*+*tgI+EF(junl46;B#T}tGUKFK@h$!gI1-I8r?7P0(Ze|D0d@)4v8R62%3)G1 zYE<0N$N&VId`=ofxx|Nx;6qD|!eT-KA{UJ8#50Mfid9>RN|_lEnM|jQGI{ll8t39D zR;i+1&`^=192xIXqkE?n;O86kIuU2@4a0DX1|F14#r^c;`Fe6DVwoFrJz$8vT$Ew^ z8@~qj*Gm-Nmb>k;O7T%>oI}o|Q+4E<^D@V!6LV~Sn@#v>9>ARGk3f4D*g}d~)zU(a z=*LIj`cuAusUWe?dJ_658~<#VPHnVOgjolPQmfRM8d)4m%uO*i@US)JC+y(h*sK_Q zPX@T;vJsFv^^)-G$-3wps!F@$pkaInv?%(#*Dc^EepBuG;aFTz{V?4L=fDj3z^Om) zm9HA8dO6Yr8drwCp*D3bv=cs2-+EV)ZHk<43vH64%}23*=wLr$!j@8pz)N~tN>6MT z|KCe4QlV2WR-rNL+Ag6EUapU@x;XHa*{jK19*2#FBs*`|Kpj^cQFPF|{s!F0H>)AH z_I+O9dP@$((Y3`6&ggO=-XglKG|^@;I~)NsS1ot|X43$kTROx!y<+YO67R$~eAs#K zn+fMCgXB!1x0w5oi@7i{*vF50Cohk`Hlj%DDfHZ=s@~X~Kc>`}+3)bLzXwb7};aNC44e@(+hDyo4?~r zhDg8`l^>L)-l8ua>$h*hW|zR}r{Isw6mG~on})qdfGF1kxDVm z4rRIr9xj(#|1#0gkP(%|cDjGF=qV^Dj&Tgtnzy*>CWGyW9LH(d6kKr(C8(Y;m~}Z= zU|BIR4sqb}mNu|IfM{8g@wkB83nX)unbrU`Oy-}n=yB(rutBo+2ytMa%_$M7;c}_q z7G~jGYJ825wld0gA75M36@}redOsKg;M}&Vm+9^Lm!@m_%dF13{L*t*@F>!-6RvU7b$_ZtDU8BN5!0vVY7H2vmuSTpvF|C>Qa;OF;(MaMn7kL@7+ zCX)I6GHyXX^QPW)%U>SRSb*3tZ*wnfMg73$p2DaeRW4z<2;s5tN%2r;ELpt%?zqVZ zvk&aJo4BBTMBQIo{fZDSP&4J20_ozHL|JFpAO;xHzt!%x5vr(uJseZdf*+6Xjr|y* z+l6qi_Pi4PHkAV;Zm0*SGpCdllLvf1VV#tz=cY5$7%(sjwCITx?cdZ&qf-{t60MUs z5kfsQ1O&7JKxNQ}pzDsrblMlh+;vPUH1ANTvrNh|DQH5U5iRBj^Q>Xm0Jysz?ppeV zm16Iq1H-kq#qZ8Y{yqRWpIdZyONx$uhgQ}i8EO?%Ivm!f4xDKeia#pvp z945KWm}S6)XSPNwnoJXJx$e8TQX^7*>AARSYvgOfAey-phdo)CeaOx#EeB@lR$s}B zw1VHDoY8YyEwkWsQP0_3CjhLGSKSh&dw|Zw-4%lKSqz{|KR)5#!t4{f?T2p86*B)u zX6+_a(@JZBb)eB*{e{jMa=O$v4Fi2^lxqm z^2TVVv(RH+pg9?=22$L3XPOX2Ixtp2gQ7Q-kdvDv2IkF73dUw?wK^1Cvq z)5&S}ft3@wvVBF;X~pt>#S&`OGWD)=_ypsn)dck0C4d};iibaT7U=sQ0?{@6_%#mk z*RL1S|3r8Z{?CM$#D6Oz|BdKkiR!i;phpg_&4(Xa1UorM-|;U9nM6@6l{|!?eUbs+ z7)r^`1waY^do~MpBBX+@!ZV1`bANyT{s{AHh91ejTbs9`aifq|!N{1_g#b-)eN83G z#w4?;C4}&KD9GQ?y)W(z=v3+C4F4XGV^*+(v3jm}>C?#QgoLSb<#F}9=Om5jWGwUr z^G88Sr)Kra0YsqR)0qAD2T22SYv@(X7aAxAiXH!{Y*pi8ww@!t!QZ(JW3D*_>aeI{V$ZfgsqdafweX9{~iBn^!~RS zW)eRk3B-UL@~z(79MfE#8r`WdYq_qmXxP0Y3J)S8PwtFiHg92lB*<6|Rlh^jP96Y- z*B6R7qbwo}OMIN=eGEl>jaO^;%e8*|h8a_*7575x>Ph5e6|7~wxv~Wdq+XdjUO^p- zP)=SCilhy}d1b%k=qYHOEwFC|Os6KQY^`1o%3V9L+-Mugi>6kf%Jy^84NOwuhk7TV zV$pmw?VfAGbJB-?%{0%`aiC1rUy1sMiI@C2aCEeOcG|-nv1W1P**9S;cbHk|HU_S} z(EWrYDE)`I{88Ta6vHk(Gh)MLNNEyf_1pTTM#Y~31!0rfKn2Ihz5m; zxfwDls<}?SB@70nzC-7Nhk%1wl%q@3p_zVwDQU#yKSr7ZwA${PKZNe|ez22bDc;YLj; zB`Stk%1lL7rDwO+qyuzF9@SxGkvWTx#TZkKRVQG#G7d^r>`W8fQZ-v$Ot5wB@!gU2 z=d{NckJ$5ZYv%JKNA0yV5spt%_*(JWq~mm_|MGmh(;FXg(})x_H8e#mGzBbGERm?N z-klgpiZnwsA&_)L$#f&CNJbh~Y+h#0fT@El8%Kf;4n}0pf~n2h*=<4GxQT+)Oq<@k zMvJcv)Jo|b>OHjKmL2pdqnufGxbN+#dCF>;_UF9Gw6TTE$&!MjBlQhq%_Qkd+tus{ z=#i-upYlH^W$Biu(nU=1?i=`AN>h*V-SH{z#Pyvf>wS-=%tmpEV;FK*TQ((WsBS7N z+r?yT^zERD?})d?#&M@rl2BZ1N5}Q9%jU#P8!57xL_iK49v;OoYDu;IB_c;(=G>Vv zmrEN>B88@$n>Z4}%r;0?H_nwRy2b-E}q^$T-tmCa%os+He(WH<%*%s9f zwit7hUt5GG(e@rva!qC(!B;k7xfU|ME4!K!R6Wc=vYDn%sv@B)jP6bqX3^|U#Ygt* zMC3DA%lyY|Vxmk(I_xnQHagyF<|Q%KcZ%^@Jj&uT>X~B@2mqd^%PV_5IU{vBQ>T$A zr_CyOO{VkQ7?x6EY^90`-(&+dd75OA)lj7DqKf%77+H>rp{PRy_30VG@ott}vd?6& zf^MNs30L8yGI_G?18Ged4NqkTvQyVAR8X1~z!n0OKo)bNhrvW%Gwr8LH1YiV8G}Jp zJ5L&v$jMLSGj&TtSr-mx!%ye|3^OTdcx=f1=nj@eHXViGl#b8(^yj6)5XYqPGo+0P z>xJK}P z(~9iD8Nh&0ct_%;>0kn;iBnIc?nSAIuI*VDqQ>iM6$y3;S>VO)Y5bf=3$ zDi|MnfWml@lcs3$Wxom=3kyn!0xE)}t`M;er!Oa!t6qf{1(g;g0HILBMUhA{km8|n z-BCna*nlv-?_2t-J=j`Uar)U*MiN7vl2|Zh$P66vR;mx@q>C<sLT!M1{%^%Bu#W2DLA?S-jnwoxuJ zob4B+rkFmn{xK{$C*sZ2AcoM;@!#5JUJRT zTtGvAK$AW@a}E50o5Uds1B&UWTp!-ylg7K91v@Q-=XZ6cPw>Zvk#=#7Nc7I)C75q- zwy#RLh}l(k)HCJ}_$;-TWzLb#)O~9*ZikyUv!BjGrL`4Aw|$=1n?F8?8{C_yo8>4@ zNb=XdKsLgfeG1N@}PGE!YqR(Y$`QOSjoebdU^ z$m+=>=3iO-YV(NkEZWd%ASYOmRl3Q#-IQ~vQ~U%bgZ>{w$@R|ENa)kXq6_&c-qhA9 zb!nmVB=DPr2on*a84r6w=42eeI>Yut?Jw~CT%pt!Fk$h)4HjW!h+zQ|by_{l=;E0r zs*?CA5|K^MBl^l={$d84LSu{Kz)!vc!7olOAm;L8b-evqKa7>r4& zG%Q4o`zVDJKI{673e738BaluHk0LfC{bn|Aqlm|0P+Gg-e{?(PZ20n8przC@oQ2yt zD;w=dq;OK&!0s;(gPwsP^-B4|@TV}inmHho4W=AM{u1)kP5Gv37`3>t223{s?fw;k zpx_x7-~DinD4|<~2M{}@jwC+08XqCvj8?xB9W;&yHgV{a;GZ-OcU10!^KSOZ?46vlp>-VSQIxf4wg>)zTl+(Dv)=b z`amUN*ozvhs%wPN=jVCi@&+?sKK*4<)U@cLlJXVhaZmGBtv>1}P@hc8eBwrb=)(^? zySknL?I-6K7d!KZ_JC6kd19z|KIL1D-`YpDQt0){dW-hF6wB2lNXw$MzwDv=upYwk z&&1kf)5XSCd;bJ$OQ%LX5Iz>++0U7n_6chiDV1RLi~490O0H;UZ65@nY8posNO&^= z?G34BOb(HeRy)Wmu&Zzx@2xOX^1jZV9<+Z6%Wk<#`tT}KVRB-BB~QZ^J-X7-B}wbe z_-e0S>a8elp>i&^3A)(wFMzN(RA`ArD5$85Dqa1No-E_`h%B37U>>3!Ha3?Xe zAzfWwpIcqcEWN;%Lazk)+sRhr;=m(qd>k_+bFQ;tF45&fO=Mue|$|GA@+-8qD#m@1TWKT)cn`^<;QcAI4;zFqc)OO#MjZR;= zas*|)Ri~%iDD>y&mhmYh>8Y2-ywjT`+2oq^E2-Bncly z^h)%aOre#rZ%q$}7HLw4?!mlbQpD8vEZLB~d#^KM=V$}jfgTl5SOvihf<|E*SaxK9MU0 zL~bDeRutdKkcElv;_jPk7lwXdmIC8UPsQry9x@;n-lEY!pjg|&j=gfYeUv_SC2sr` z@dhHCsjshWN44}0@Voza*O3PM&ja4)6oZ0+o#E5O8XDbeNdGsP8exL?RamroEmEo% z$FK$5b{_`VnBUT6>RMQ?PnUeGpy7Ic_d(dBeVGArEXq}?GB!>OF`_AVHOabx3$(8t zfOAd@@QqJw|FzI<+S>6R-~A1MN9v8ZVton&4OMLlRW_W&H;3OZEsUjde`D%U$zM4Z zxsyfeHU?@_nWI}>EW@-hE%NQ;eZ>5a7<^buOD+3j39)pBp}|{ z2y8fPQwkKabg74}cVs*t5MRiX0Pr!19~SWGbhKR`8pzGwao>ozRF>kVOo}pXFLlV7 zC=Ci4^<%AtE5^tzGyogXmcSx9#3}LPlF~&leK=v}CK`6)4opD{*TPk)zsa{1PE{qE zQ)DhA@Dtl#ax9wN%b9n20cA+;f%8PF3DwE;O_YfhlzU*9cqf$0uq|Jt$l1`B>ct8@ zD311YJtMvp0CztR?O@i&!Cx_j^$3Npfr-TM9S!D(;eKsTyca*k|?b6Y`1 zr1R+%zAi#ZeQp%-!NpukELW2_yOG1^&Q1?qOoRNA#NtuB<3xz#%S(4$1z6VZR) zWH~;#jg)ln^M}0{56X?W`=d(Pf$D{$ucbv=G-sz@6pFnbmpKCn#HC8I^F^;pUAQQ( z^@w=ypts-K$bu@Nr%Vd!%Z(1iJY9WCs(`QB-5*WY7X#h@?S*W`xbW9?#LT{uU9xhNul6+ z4ylUN zeg3Ll*yYjJNPhcjU4@Zm{2bmHKo+kdKq86!6eUUH5% z;tYcI8~OmpEjc}%f;WoWqi{3|%820I2bDG?U+y$v_VJ@juTV5dgK%QQ=iub1j<9@|6bV}1kv`DR6hW@AxGH_R&8g;`QqPVS zLy4eMh*4%ZI|X6_sc09qDkZ#41;k9f@t|DnE~UKCDB7;*ZrR4#nk{#@ta!b7RgvQo zh8SDLNv)*oOtpLXE(2-bS7YWHuq zChacV4;|i-b19-gDay+07AU*08=)rEoMz9UM4PU%zJrU9^ByS|BTJdJ#GR1H(RArR zT6Z5MZV|~+@bf{T8V>1z8cH6t78?+T!sdG<-7TOf5m7*4UlgQW#OfGRBTtRMQ5YXs z3ylbh1^c}M3glR!7r#ly#!Qfak1a6@gl@rnGQOjz(OFKxjd+w9EckGCm;M4WCwVI%HOZu9856^>RNR+x<{DN7fdn)! zyO+ItWnX?2pRbp|cTIji)_f=%8#EQukLaGCjUU_uNh-&tJTVt-RHDiN)jGa1#jA*( z6o6})GUli)4>6HV(;dc5&#fG~kv>?p(R|x(CkrzY1uL&*uS9rxDpVY5KjdA#Z^gB% z(kvnhNb)sWOo~iZTRuv5HMlKmq3-gjum^a&YWHve^G{QN&ElP;I{{YCJq0;U_QC1= zp|uhBeCp;?@?wIuh+O3w23K6CyapDjJMwk?PteYHrE%PW=} z{eu)JF+wa61|2r~wb7bRb=X-$H}nmWw+~V;pSTk#RRkdyM_^sBSq&geW~03{>?4oD zV||Xx!wy%#E2kj^ep-`nK3BoUZwxhduPSTcn%2$CfM|3B$i@i}&ZA0oZvqRd&nag$Row}Z3 znrnObbTaJ?E`+wb$PdL=#kINI_CDQM>`=djl<~c4k)`f38xUY&z9HIAp4l5&3&o`m zR++1w`y&3GltE>PcFkxn?J>!O`@s?YJyCuegyg6GLD3G0&I!L%p|CrWZPE-tC=+Ek}kmaSV;ypJmXA6&fBoMRmBs z+@f(Z^d8kPeE_Z%lmyAN_>XJcxpg1KYni5((`8JcwNp=J>|u!q>UQ=C zdR1Z>*Y-iEb}&WJN@u2h;c*=jb)&r8j>SHKtjW4fmXGdDQG*+p_0TAE9?#kXi*SFk20jj@E>g3@KP4`)kA4wz0u)PJZTO8T*qoYP>&Y|g^0SV{HEpHX7FC{g% zT%XDwcI=y>K6CD-B*CZdS8e*}nf^$Kos2XTVPW@S)6sn%&M@TRee^siXxFSZ;+ktC zl+G1#<3j7mIud$x)M%^or@aR^g!3=DU0=>bT6nR;9*JPOp6e`}K1kKGuCJ=7sBTU+ z@bBXU@y)Tc==nshwQkEE-&E-dJ9@YoR@8+ z4Ppp_4Q+W>)d0^dXlZaCF%yG5CV!zIzi(^i2sNs9BZ5b5G&q zzVyN_ViL%XoqLb<#5)3B>f$|>dY9zvprkyS)Z@`Wcu40evAPz72sLS7OR^VXX9v5P z#kr8xx&M{J>uBGLSVQE*OC%#@o^xyN8f&g5T|XE5YC*lcm0jV_1`i8(Z$BtQ`9Vij z0Z{~b;pg*VERejW(Iwzm#}>30H?$+zYhmia1{EZA0+DP@VX>E33mtjQ(ESwh4Uw1! zN)o4(qb%W2CHXa8{74lbg&owx1D4)(Gjr(MRO~|H!#sHHH-Nm)TcR_Z;&Up8f@Fcb zZ%8Kwann~yiBV43gJK4ko^x?&A|rsVP`uzDWKbn>uDCsEs=Z%Ck~DfHVUMH_OPy!E zP`X@sjcq<*oy{q(o6dlwj6UN|7T29L$F$gKP&Z*VKCkU~R;+qC1V4lBFe9V_x~f9N z4hlEiq`VdGq$LRvUT%%;UUNOUCwVe<81I9_LbgEv!9Qf*huZv7ygTNSVd#?Kp1(5y zl2neJowGcPe0w^?NkGublY1|9g~HOvUC_H0Q^8;Q=>7Ko5o26*pWHlSr^)7Wz05wt zt)Tc!s@Nn<q~^B_&I^ha|s$Bh-xFc84FI%B26gnErYyn=OnpN=N#Lo{*weM|{z^XL||%xp)ebaU)aTI#SU)5l}# z=kKnLyi*=ozi3QWRgZ{562J1VlyEm*1fYkPtNIL7Fm$Yj+GdY{yh+~;zRAn|qD!Nc zLTd0v^=8l@L6wFvTIMjO?H-gaG?ZHaHYI&^3lJ^$ts9-4SYh)Tn0}ef6qA#O4gF9nK#?RAS%>siYM`ywu9K0< zp{Qd_bG7KueJ|(Zbx0|aO1YYn`l{!Sx?Ey*ogM5Ta_ zq*&hB!;efrVT!%ISp{pM$c*-`R4;Lo?(6%z@sSfRcI~@+sh|(ogV0rN2-PA4Z3_;) z1mPZ!gx`0TlN-&GP!{A_+8qzaT!)aAg_l?0BmA~~PK`cuQSB86=uaIsB9%!}A1Vc6 z=tsIWNbBGLoM*ebz1wZ5)et4n{b*eV8BGU|U^CvS^u}-1BaGChPbm~JeJOYvm6i#T zN(O3scr*g~P@} zTEwv4f+8}%8=tUdTS3H*Q4V#koD!P=t_u4x=*`0I=S+1X!LidJ>g%pTesoB?jZjV+ zflU!BZ^*n-=GhBM`U@V~MP?RBP64TD6e=)zgOd0@D+WZ_p(bt(mqqDule17G%F`f- zb|TT`iJs4Df~taED&%}J4`Np3pm7C^>PozA!-#y-PZBXCMvYp5k! zKHWZf+^AFKx<6an5E`>_g6k_?HNW)rFeWax5z=}7M*fV$2jzPFjB&F_YoxX-4eh1)VI(l=kBfZamb!D z{Wa&Pu{& zbW+viAKnvYT;UsVR5K3jp3@^#60~AVhY5VBINdL})kALb%rg_N8&n8uwpO;L&njf7 z&e-!+b-Z(i+eE7~75eR$9lUc-Eeq-0qzvOTJ)e=`QAw@R`cZqQFp&Mv0q%IAH`*)8Eko-E8Dr0N*pWStX<9FpSgfK(q z{ac^YCOnVO7qVBeeqi!Kke=f)@a28U>o5-7bIw`R?pB{N5?mw7gQxXK9GV!uZqT}M z7j14p#^)7@89SyY8AtW&GJQQ&dU}ob)Z38S((>}C->O+=Y+troydx-V%wsC3wekdh z79MsEubf7IbJ_Zc551zgJ21DA9(+w&4&!VTs^zb)_$<%dvD92DW*Wu5Xhi+3dXrPn zSCJ&q)|`D92dbQk`o{X&l1ActMq%SKo#UUqhaVxcmDrkx^b;fv=$_3S1(mAkw4pZ! z;I^F+3#>pO?82VP^B5lQ^kd1qbLS{pE%s}vFp*i22}OX2coR?C7Knyf+v#nVpawqvjRlipV)kI z0(D@LJff=Bky{`HhO!3Zg>*4NBb6hEQ^R#j>Z5LQncDkHtrhP=AonG2%e${rxx#|- zA{-j0Y>rIecNtc)$k%he)W609fQ>mhJs;QmJRPB04Uln@_fE5_y$)9d2`IjPd*#tr<|?ne$)N%$&l>S~tzOtVh)&24g%jg}sp# zDfInFUnO>Ufj;E-qkw@Tk{UePyIeCOk?<^&F1U%PA01^^ZVu1nMqY~hc2msPg7o zwXrN7z4+*mW4tPFgsu5r1@^_K;A|Qsru;4&{0yg78O?_;_T#j}7FrSZb3RRnM>s6@ zHJCLz>aSg{A3bP(iVdf$r^`ejuPYsLIucb6#HnmZ#o1z)PUyjyJys%mxE+EiDeW~G zH-^TGIXA6q*mLw@oG}$wE&5Dl99Kj+x2#2v=U_K$(ce3#o)Qp@wa;^H-!5AWvmf{PxKVreFV@K)SI(8a6vwm^j{b@Rl$D z82{%l!9t$pvP<q*| z(Z0^>eeoipH^0EI3NI^2)(xjJs?~8uR>BCed0Xfb7eBlDc$`m8U-U@PBY4{?Q}5VN ziK-Xu_GB$VNu^g3?@7mJ@}^i?x!BUQ$r$Eypk#>io<~9=a>_y1!OO0R7%~=k4ztUW zK3n|9W?|C+ZXFj;Zr3nD$r{&;?7WLj?fpQ{Av4vFh)!5eEKa4C+nQ4&?;SkKK74V` z$&Syd>JbHWW7JQD8FLZz!_S3eBv~*;?KNJsvJ4-kI=&em_;Pt zP<+^KA95qMQ(=>;oPxSWyJ3oWP)JcpL($fC)C^)?pQqG0oCCzCLvjn#}xs_wvo|g?Ntuige(3spM;T? z>0U{&Ie`R`<|t56T6AP5nA!@GCTgDXQYAN^v&?l+3ilgmx4^*%kJL7Eio)UyVu`^k zi3EzmFZl>q#k^h~;R?1o65uWg*sysjkRCGFw>$J!Y z)ljpmxzUHbi}hOyb(DJ9Ybf`*<1A>Aqo3K$lM&pp9=$^r;l9}*sGT-L*Zqkyr3MkO ztaP6N^lm5QVW+ZV6(&QxnTAhV+?fG+^vW8ls6}x9vOBYePh&^f$?f;@r^X^O-V-PKI^TSxDF+S350;cZmjXSiBZbUTNxPj zLq-E-cO`ub-_}(1#ytYl7bOHsE^h74Hk`FD7FCs_F z&?=bN_*&Zrn&pyF|aSub&v_bPom|X`{_O6NI$#(g$*n)+ak!HHQT<; zi~FTNhU)00jfj<7QHZMxOKKAc#lh#4n_oX!o*#j6rXx8wv6XIk! zDdZaBhw!_V&VPC2@9!F5VOxNyg_*O1!7r++brfxEhZHb>ey5$GPFC1WMfHHXfLAbkNnrnh z&=LWM(O%*z?H9@&*F#}3(xw{+Bk1yl?eL>twlsYEs~8?d2Rn5o23bNH550S*_evkR zAZb}zr=Fd$MZwNkZ(ACm{+u_?iw|4TlVma4=(MMinYxGy-K>-Fz^FuHX3r{WFK(a# zb`2M^&FPti%k_t7GMy~t$=2bi-UhP~jFMbS~1)dv_swc+;yR zaRvm%=}>iV=Kkbt>})^w6KJFZsG>3`w2wQkenXu4OW1iwgoc0dEyi%`m>xk^FVMdw zF4O$_g7|mTiMp8>Isc+Jsj7{&(m0+EYNb&}1Dqj0iU~(Z>?e`~^VCQ87%gbtYe;6} zZXj!XB)FWY`E#g zKgk=qkx8vUvoPte87D(cwkTh5oImAT_s%-XJoWBB--y@qfm+b5r{3SGaSXl8jEvr% zl+@@Gz08~RVt?41`ZoN z&&%Jay$UHWW21HjuX==mF0jYetv-`u<-b*Vt3r2C>sjaNktdHp3L9YY(bQzsCyxd$ z7pNkE#qdlmp@3GpRQXM!M-S0yM%-koji!$qoNDHX{B}XEF4G&-=&S^d=@{(PLiNXS zm1ITttkAfUQ*AJnLIE4D_Zjp=YUEzmNwmq(ri3NE*XSdF^Dlv^TK_oSGQvQuY&i(X zvz|ahbaG+>ji-x`vqH<8jkBV3mpwrgn5sU!1+R4!lB(v(28QrzEw*W*Kvxu&9^(g8 z3wzt358%6>+2crlVG|oI;VFr+I>X#UHN+slw~e18yeiczH=Q&h?IQv=1(=HK_OL?N z8esZRJ9%@pfxeF|`;7X7kG*@KNrx1Up^y_f(}%+QpJLwlF>A3GDGEyk7Z7A&tH&)9 zGZ@ta4edwh;O%0XiQp{8XS1*-R9!cX5u@-{TT4o`RPgC@)E-vL>zl<>Rar*Qw@c~8 zlFifF<>%&Tm0Q=>&;Fk^a@YpH(y%p!E@=vX;9c2clD0#e~7?+G%>`kVe5Pwmh0u0Z;-Y!^`Vr< zvzDku2#)8IHwW=R)P<%9u~=>5xo40meq5mS6X*|2&wE{*k+YN{o^UeqyqU&;P9Lyq z&A8=ro#o2P9QeA~z^p?L#{x9yf!ka;3wj~>F+*b9O0`)&>JhsHPNJ<~4dAhM!Xx%{ zrx)=vq*YH%G~>l1U$I-cKv6-nzR7`ABBPVONUS3hV3^`5#<4oo*Uh+87|yGB@gO&R z_`BL%Z_wN$X*d#BBiMNsb>qnjOIMx@_FrUSY|0ZiGBls!InsCvpl zSt{+RX0=hww9oZ)pPLV`y9<9{eAW@h9ki7?9h;k9y0|Umg*C5_&L9bT55FG}f-@7= z(3wnkJSn6rhj4Um(AW5W(Zuyc>%nX*YwcGVRn?i2GaG-D2#ioGlQe`OmRll!1o56Q zjN{G(19_3htCJGqo*)WE=WXFNaxrejuMKgZBgkKw*>iR7LFt;2^ropwR;9T{VW7~R zx>(M6GA*KDb#n%~$*Q=tWk$=WuO;w9^0BUPk34%dS#fC7TtqlVfMAA?6@p(q@0H!o zb_0@2aZb@N16?OePY+v7(WIx5FF)vV=d$cnAw-rF79_U6T{RzQ*Ii#{Yi(nmhZ-dVG3&twm6NnLT_ObNI8D z?$%vkPYeUw$NUjCY!W^Cp6|@yc}o_T-55qF>e)6(GFxD-=iHHvpIkS7VmRqrU?VY{ z<$G$<+KA`%bcZMnPAKN&(lDiGW5fK(PFnlmfKk_}y*8diwCu8*dBW22gQevhW(C62 zkGU+iy=cKm ziKR>`jUIR~ZLiCmiAznM<9Ig(2*lq%c55G+bMrv8?Ooa}GH9*RQ-LZ?B|i8xob#2M zy9@Vff2$vFji&NtZ*_Boa~&I?qrtG*{b1K+<(bv+mQ6G}ra=c0>Z7%WD{}d(2ohFk5GQjh zzG|CYxM@?dHVc%Y3IIpv^VG;b1nQo!)`Toj36NwO9`dz~uBTY{v(9Fkl{a)cc?MrL zG&|LOQud7KIzd%k(g7}bA}&&^2WQ2NF@Jz}RF81KID^_gunIaLlk^UXIz!oOwUyj) z^+LFYc3qAMTacfK6krSYC5w+K!}1m7y$P{71~+3rm-PqDl4Ffj7kXRkoE0c}Q6Fxd0JzFy#jzttYd5r2)Z**Zu6(rTZo z#JUzXt2$RM*)J-lT7#v>@uYnyC*q|CF&7pP)ewcxhAv%~I$edQrHQ7{yi#F-c7NS| zA@la$-Q7NgLAu}|gt>6_FXjUDJxnM}C`3dE914mI>O)l?@g@osR3kMM6guRJ`J>5- zDhn}6%84<{ND7O}DT%5m!$CprLhlxPKf1B(@77F&d|m^2VgBHT1=6JH|J=w5$w`Wd zDk(F`iv3qtM4@h}cb#R_Ag{ZgJl_l*4GPNE!Hn7LU+tTijjS!0zr*1QIhZ-yKzfct zkX0sjzgSEDe8^N|&?OF#RYW>u6>*2#{bmv9ztj08FY;&SQSWJQB_Yn!AdBNWyx}*~ z#Q2@_zX`wx_!o0!@ zhJR1zPjwtWOAZQ?S`9$r$qaI8pZy@YiuVVS(f|nI;jh8{HCzbE!^z|yG_9x05;!3f z8UUHlABRax_6M4>7LJY(p4nfjkVyFxNw99gQWHdy9@3lp$6*dr{DI`(TCN5TCVxIm zz3W43S4iMc+V&iDsHDgdkhpYZ&i>iy%7 z2?xtRtNz2>eb2i6(Rl{Pe>nfwO#KEE{_~gldqnFUF87@w`o4YS9bMs@6)F8z>K{V=`&NT@+g{&n#qgJY|DYA+&&U3K`@J874c+878h>wm z{Pz}yKkIy7^?cXN@0;nH{+Z4{OxE`W!*|8g-|U6^F9rTvDfQ1s{(UX$k7vQvYMdv{Mz{+(jsonOut.trim()) + assertTrue(report.success, "Pristine scaffold must have 100% valid validation report") + assertTrue(report.checks.isNotEmpty()) + assertEquals(0, report.failures.size) + assertEquals(report.totalChecks, report.checksPassed) + } + + @Test + fun `machine-readable failure contract on nonexistent directory`() { + val nonExistentPath = File(tempDir.toFile(), "nonexistent-dir-" + System.currentTimeMillis()).absolutePath + + val (stdout, stderr) = + captureStreams { + val ex = + assertFailsWith { + createBossCLI().parse( + listOf("plugin", "validate", nonExistentPath, "--json"), + ) + } + assertEquals(1, ex.statusCode, "Nonexistent target must exit with code 1") + } + + // Assert: stdout is valid parseable JSON containing success: false and structured check failures + val report = launchpadJson.decodeFromString(stdout.trim()) + assertFalse(report.success, "Report success must be false") + assertTrue(report.failures.isNotEmpty(), "Failures list must contain failure entries") + val targetExistsFailure = report.failures.firstOrNull { it.checkName == "target-exists" } + assertNotNull(targetExistsFailure, "target-exists failure must be reported") + + // Assert: stderr contains NO raw error text + assertEquals("", stderr.trim(), "stderr must contain NO raw error text in --json mode") + } + + @Test + fun `linking unbuilt project fails with clear compiled jar requirement`() { + val pluginDir = File(tempDir.toFile(), "unbuilt-plugin") + createBossCLI().parse( + listOf("plugin", "init", "unbuilt-plugin", "--dir", pluginDir.absolutePath), + ) + + val (stdout, stderr) = + captureStreams { + val ex = + assertFailsWith { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath, "--json"), + ) + } + assertEquals(1, ex.statusCode) + } + assertTrue( + stdout.contains("No compiled JAR found in target directory"), + "Must explain compiled JAR requirement", + ) + assertEquals("", stderr.trim(), "stderr must be clean in --json mode") + } + + @Test + fun `links plugin when BossConsole is offline and stages cleanly with version-rotation`() { + val pluginDir = File(tempDir.toFile(), "offline-link-plugin") + createBossCLI().parse( + listOf("plugin", "init", "offline-link-plugin", "--dir", pluginDir.absolutePath), + ) + + createSyntheticJar(pluginDir, "com.example.offline-link-plugin") + + val (stdout, _) = + captureStreams { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath, "--json"), + ) + } + + val jsonResult = Json.parseToJsonElement(stdout).jsonObject + assertEquals("staged", jsonResult["status"]?.jsonPrimitive?.content) + assertEquals("com.example.offline-link-plugin", jsonResult["pluginId"]?.jsonPrimitive?.content) + assertEquals(false, jsonResult["running"]?.jsonPrimitive?.boolean) + + val stagedPath = jsonResult["stagedPath"]?.jsonPrimitive?.content + assertNotNull(stagedPath) + val stagedFile = File(stagedPath) + assertTrue(stagedFile.exists(), "Staged JAR must exist on disk") + assertTrue(stagedFile.name == "com.example.offline-link-plugin.jar") + + // Verify version-rotation pattern: dev/plugins//v/.jar + val versionParent = stagedFile.parentFile + assertTrue(versionParent.name.startsWith("v"), "Parent folder must be timestamped rotation directory") + assertEquals("com.example.offline-link-plugin", versionParent.parentFile.name) + } + + @Test + fun `links plugin when BossConsole is online and live reload succeeds or fails gracefully`() { + val pluginDir = File(tempDir.toFile(), "live-link-plugin") + createBossCLI().parse( + listOf("plugin", "init", "live-link-plugin", "--dir", pluginDir.absolutePath), + ) + + createSyntheticJar(pluginDir, "com.example.live-link-plugin") + + // 1. Success case: host is online and reload succeeds + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock to simulate online BossConsole") + var reloadDispatchedFor: String? = null + SingleInstanceManager.pluginReloadHandlerOverride = { id -> + reloadDispatchedFor = id + true + } + + val (linkOut, _) = + captureStreams { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath, "--json"), + ) + } + + assertTrue(linkOut.contains("\"status\":\"linked_and_reloaded\"")) + assertTrue(linkOut.contains("\"running\":true")) + assertEquals("com.example.live-link-plugin", reloadDispatchedFor) + + // 2. Failure case: host reports reload error + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + error("ExceptionInInitializerError on host") + } + + val (failOut, _) = + captureStreams { + val ex = + assertFailsWith { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath, "--json"), + ) + } + assertEquals(1, ex.statusCode) + } + assertTrue(failOut.contains("\"status\":\"linked_reload_failed\"")) + assertTrue(failOut.contains("ExceptionInInitializerError on host")) + + // 3. Offline case: BossConsole is closed, link stages and exits cleanly with 0 + SingleInstanceManager.release() + SingleInstanceManager.pluginReloadHandlerOverride = null + + val (offlineJsonOut, _) = + captureStreams { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath, "--json"), + ) + } + assertTrue(offlineJsonOut.contains("\"status\":\"staged\"")) + assertTrue(offlineJsonOut.contains("\"running\":false")) + + val (offlineHumanOut, _) = + captureStreams { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath), + ) + } + assertTrue(offlineHumanOut.contains("BossConsole is offline")) + } + + @Test + fun `tests SingleInstanceManager reloadDevPlugin handshake protocol`() { + var reloadResultShouldSucceed = true + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + reloadResultShouldSucceed + } + + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + // Test success + val successRes = SingleInstanceManager.reloadDevPlugin("com.example.sample-tool") + assertIs(successRes) + + // Test failure with message + reloadResultShouldSucceed = false + val failRes = SingleInstanceManager.reloadDevPlugin("com.example.sample-tool") + assertIs(failRes) + assertTrue(failRes.reason.contains("Failed to reload plugin com.example.sample-tool")) + } + + @Test + fun `scaffolds eval-test-tool and verifies wrapper files and structure`() { + val targetDir = File(tempDir.toFile(), "eval-test-tool") + createBossCLI().parse( + listOf( + "plugin", + "init", + "eval-test-tool", + "--template", + "mcp-tool", + "--dir", + targetDir.absolutePath, + "--force", + ), + ) + + assertTrue(File(targetDir, "gradlew").exists(), "gradlew must exist") + assertTrue(File(targetDir, "gradlew.bat").exists(), "gradlew.bat must exist") + val wrapperProps = File(targetDir, "gradle/wrapper/gradle-wrapper.properties") + assertTrue(wrapperProps.exists(), "gradle-wrapper.properties must exist") + assertTrue(File(targetDir, "gradle/wrapper/gradle-wrapper.jar").exists(), "gradle-wrapper.jar must exist") + assertTrue(File(targetDir, "src/main/kotlin/com/example/evaltesttool/EvalTestToolPlugin.kt").exists()) + assertTrue(File(targetDir, "src/test/kotlin/com/example/evaltesttool/EvalTestToolPluginTest.kt").exists()) + assertTrue(File(targetDir, "src/main/resources/META-INF/boss-plugin/plugin.json").exists()) + } + + @Test + @EnabledIfSystemProperty(named = "boss.test.e2e.gradle", matches = "true") + fun `scaffolds eval-test-tool and compiles out-of-the-box with gradlew test`() { + val targetDir = File(tempDir.toFile(), "eval-test-tool-compile") + targetDir.deleteRecursively() + + try { + createBossCLI().parse( + listOf( + "plugin", + "init", + "eval-test-tool", + "--template", + "mcp-tool", + "--dir", + targetDir.absolutePath, + "--force", + ), + ) + + assertTrue(File(targetDir, "gradlew").exists(), "gradlew must exist") + assertTrue(File(targetDir, "gradlew.bat").exists(), "gradlew.bat must exist") + + val isWindows = System.getProperty("os.name").lowercase().contains("windows") + val gradlewCmd = + if (isWindows) { + File(targetDir, "gradlew.bat").absolutePath + } else { + "./gradlew" + } + + val pb = + ProcessBuilder( + gradlewCmd, + "test", + "--no-daemon", + "-Dorg.gradle.parallel=false", + "-Dorg.gradle.vfs.watch=false", + ).directory(targetDir) + .redirectErrorStream(true) + + val process = pb.start() + val output = process.inputStream.bufferedReader().readText() + val exitCode = process.waitFor() + + assertEquals(0, exitCode, "Scaffolded project ./gradlew test must exit with 0. Output:\n$output") + assertTrue(output.contains("BUILD SUCCESSFUL"), "Build must succeed") + } finally { + targetDir.deleteRecursively() + } + } + + private fun createSyntheticJar( + pluginDir: File, + pluginId: String, + ) { + val libsDir = File(pluginDir, "build/libs") + libsDir.mkdirs() + val jarFile = File(libsDir, "$pluginId-0.1.0.jar") + + val classEntryPath = EndToEndFixturePlugin::class.java.name.replace('.', '/') + ".class" + val realClassBytes = + EndToEndFixturePlugin::class.java.classLoader + .getResourceAsStream(classEntryPath)!! + .readBytes() + + val manifest = + PluginManifest( + pluginId = pluginId, + displayName = pluginId, + version = "0.1.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = EndToEndFixturePlugin::class.java.name, + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + + JarOutputStream(FileOutputStream(jarFile)).use { jos -> + val mEntry = JarEntry("META-INF/boss-plugin/plugin.json") + jos.putNextEntry(mEntry) + jos.write(manifestBytes) + jos.closeEntry() + + val cEntry = JarEntry(classEntryPath) + jos.putNextEntry(cEntry) + jos.write(realClassBytes) + jos.closeEntry() + } + } + + private fun captureStreams(block: () -> Unit): Pair { + val outBaos = ByteArrayOutputStream() + val errBaos = ByteArrayOutputStream() + val outPs = PrintStream(outBaos, true, "UTF-8") + val errPs = PrintStream(errBaos, true, "UTF-8") + val prevOut = System.out + val prevErr = System.err + try { + System.setOut(outPs) + System.setErr(errPs) + block() + } finally { + System.setOut(prevOut) + System.setErr(prevErr) + } + return Pair(outBaos.toString("UTF-8"), errBaos.toString("UTF-8")) + } +} + +class EndToEndFixturePlugin : ai.rever.boss.plugin.api.Plugin { + override val pluginId: String = "com.example.test-fixture" + override val displayName: String = "Test Fixture Plugin" + + override fun register(context: ai.rever.boss.plugin.api.PluginContext) { + // No-op for test fixture + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginScaffolderEvalTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginScaffolderEvalTest.kt new file mode 100644 index 0000000000..10a43feecf --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginScaffolderEvalTest.kt @@ -0,0 +1,263 @@ +package ai.rever.boss.cli.plugin + +import ai.rever.boss.plugin.launchpad.HostMeta +import ai.rever.boss.plugin.launchpad.PluginManifest +import ai.rever.boss.plugin.launchpad.PluginScaffolder +import ai.rever.boss.plugin.launchpad.launchpadJson +import ai.rever.boss.plugin.loader.PluginManifestReader +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PluginScaffolderEvalTest { + @TempDir + lateinit var tempDir: Path + + @Test + @Suppress("LongMethod") + fun `scaffolds across all 4 templates into isolated temporary folders`() { + val templates = listOf("mcp-tool", "ui-panel", "background-service", "full") + + for (tmpl in templates) { + val targetFolder = File(tempDir.toFile(), "plugin-$tmpl") + val result = + PluginScaffolder.scaffold( + name = "test-$tmpl", + templateName = tmpl, + targetDir = targetFolder, + force = false, + ) + + assertEquals("com.example.test-$tmpl", result.pluginId) + assertTrue(targetFolder.exists(), "Target folder should exist for $tmpl") + + // Verify canonical files exist + val manifestFile = File(targetFolder, "plugin.json") + val gradleFile = File(targetFolder, "build.gradle.kts") + val settingsFile = File(targetFolder, "settings.gradle.kts") + val gitignoreFile = File(targetFolder, ".gitignore") + val readmeFile = File(targetFolder, "README.md") + val wrapperPropertiesFile = + File( + targetFolder, + "gradle" + File.separator + "wrapper" + File.separator + "gradle-wrapper.properties", + ) + val wrapperJarFile = + File( + targetFolder, + "gradle" + File.separator + "wrapper" + File.separator + "gradle-wrapper.jar", + ) + val gradlewFile = File(targetFolder, "gradlew") + val gradlewBatFile = File(targetFolder, "gradlew.bat") + + assertTrue(manifestFile.exists(), "plugin.json missing for $tmpl") + assertTrue(gradleFile.exists(), "build.gradle.kts missing for $tmpl") + assertTrue(settingsFile.exists(), "settings.gradle.kts missing for $tmpl") + assertTrue(gitignoreFile.exists(), ".gitignore missing for $tmpl") + assertTrue(readmeFile.exists(), "README.md missing for $tmpl") + assertTrue(wrapperPropertiesFile.exists(), "gradle-wrapper.properties missing for $tmpl") + assertTrue(wrapperJarFile.exists(), "gradle-wrapper.jar missing for $tmpl") + assertTrue(gradlewFile.exists(), "gradlew missing for $tmpl") + assertTrue(gradlewBatFile.exists(), "gradlew.bat missing for $tmpl") + val isCrPresent = gradlewFile.readBytes().contains(13.toByte()) + assertFalse(isCrPresent, "gradlew must have strictly LF line endings without CR") + assertTrue(gradlewFile.canExecute(), "gradlew must have executable bit set") + + // Verify plugin.json content + val rawJson = manifestFile.readText() + assertTrue(rawJson.contains("\"requiredPermissions\""), "plugin.json must emit 'requiredPermissions' key") + assertFalse(rawJson.contains("\"permissions\" : null"), "plugin.json must not emit null legacy permissions") + val manifest = launchpadJson.decodeFromString(rawJson) + assertEquals("com.example.test-$tmpl", manifest.id) + assertEquals("0.1.0", manifest.version) + assertEquals(HostMeta.CURRENT_API_VERSION, manifest.minApiVersion) + assertEquals(emptyList(), manifest.requiredPermissions) + assertTrue(manifest.permissions.isEmpty(), "Templates emit emptyList() by default for open access") + when (tmpl) { + "mcp-tool" -> { + assertEquals(1, manifest.mcpTools.size) + assertTrue( + manifest.mcpTools + .first() + .name + .startsWith("mcp__com_example_test_mcp_tool__"), + ) + } + + "ui-panel" -> { + assertTrue(manifest.mcpTools.isEmpty()) + } + + "background-service" -> { + assertTrue(manifest.mcpTools.isEmpty()) + } + + "full" -> { + assertEquals(1, manifest.mcpTools.size) + } + } + + // Verify Gradle structure + val gradleContent = gradleFile.readText() + assertTrue(gradleContent.contains("kotlin(\"jvm\")")) + assertTrue(gradleContent.contains("ai.rever.boss:boss-plugin-api:${HostMeta.CURRENT_API_VERSION}")) + assertTrue(gradleContent.contains("https://github.com/risa-labs-inc/boss-plugin-api/releases/download")) + assertTrue(gradleContent.contains("artifact(\"[revision]/[artifact]-[revision].[ext]\")")) + + // Verify standard source sets (src/main/kotlin/ and src/test/kotlin/) + val entrypointClassPath = manifest.entrypointClass.replace('.', File.separatorChar) + ".kt" + val srcFile = + File( + targetFolder, + "src" + File.separator + "main" + File.separator + "kotlin" + File.separator + entrypointClassPath, + ) + val testClassName = entrypointClassPath.removeSuffix(".kt") + "Test.kt" + val testFile = + File( + targetFolder, + "src" + File.separator + "test" + File.separator + "kotlin" + File.separator + testClassName, + ) + val resourceManifest = + File( + targetFolder, + listOf( + "src", + "main", + "resources", + "META-INF", + "boss-plugin", + "plugin.json", + ).joinToString(File.separator), + ) + + assertTrue(srcFile.exists(), "Source file missing: ${srcFile.absolutePath}") + assertTrue(testFile.exists(), "Test file missing: ${testFile.absolutePath}") + assertTrue(resourceManifest.exists(), "Resource manifest missing: ${resourceManifest.absolutePath}") + } + } + + @Test + fun `asserts non-empty collisions fail fast without force`() { + val targetFolder = File(tempDir.toFile(), "collision-test") + targetFolder.mkdirs() + File(targetFolder, "plugin.json").writeText("{}") + val dummy = File(targetFolder, "existing.txt") + dummy.writeText("blocking") + + // Without force -> fails fast + val ex = + assertFailsWith { + PluginScaffolder.scaffold( + name = "collision-plugin", + templateName = "mcp-tool", + targetDir = targetFolder, + force = false, + ) + } + assertTrue(ex.message!!.contains("exists and is not empty")) + + // With force -> succeeds and overwrites + val result = + PluginScaffolder.scaffold( + name = "collision-plugin", + templateName = "mcp-tool", + targetDir = targetFolder, + force = true, + ) + assertEquals("com.example.collision-plugin", result.pluginId) + assertTrue(File(targetFolder, "plugin.json").exists()) + } + + @Test + fun `sanitizes numeric prefixes and reserved keywords in package and class names`() { + val testCases = + listOf( + Triple("3d-viewport", "p3dviewport", "Plugin3dViewportPlugin"), + Triple("123-audit", "p123audit", "Plugin123AuditPlugin"), + Triple("default", "default_pkg", "DefaultPlugin"), + Triple("while-loop", "whileloop", "WhileLoopPlugin"), + Triple("fun", "fun_pkg", "FunPlugin"), + ) + + for ((input, expectedPkgSuffix, expectedClass) in testCases) { + val pkg = PluginScaffolder.sanitizePackageIdentifier(input) + val cls = PluginScaffolder.sanitizeClassIdentifier(input) + assertEquals(expectedPkgSuffix, pkg, "Package suffix for $input") + assertEquals(expectedClass, cls, "Class name for $input") + + val targetFolder = File(tempDir.toFile(), "plugin-sanitize-$input") + val result = + PluginScaffolder.scaffold( + name = input, + templateName = "mcp-tool", + targetDir = targetFolder, + force = true, + ) + + val manifestFile = File(targetFolder, "plugin.json") + assertTrue(manifestFile.exists(), "Manifest should exist for $input") + val manifest = launchpadJson.decodeFromString(manifestFile.readText()) + assertEquals("com.example.$expectedPkgSuffix.$expectedClass", manifest.entrypointClass) + } + } + + @Test + fun `assertSafeToPurge protects git repos, user home, and arbitrary non-plugin directories`() { + val safeDir = File(tempDir.toFile(), "valid-plugin-dir") + safeDir.mkdirs() + File(safeDir, "plugin.json").writeText("{}") + // Should succeed without throwing + PluginScaffolder.assertSafeToPurge(safeDir) + + val gitRepoDir = File(tempDir.toFile(), "fake-git-repo") + gitRepoDir.mkdirs() + File(gitRepoDir, ".git").mkdirs() + File(gitRepoDir, "plugin.json").writeText("{}") + val gitEx = + assertFailsWith { + PluginScaffolder.assertSafeToPurge(gitRepoDir) + } + assertTrue(gitEx.message?.contains(".git") == true) + + val nonPluginDir = File(tempDir.toFile(), "random-docs-folder") + nonPluginDir.mkdirs() + File(nonPluginDir, "important.docx").writeText("data") + val nonPluginEx = + assertFailsWith { + PluginScaffolder.assertSafeToPurge(nonPluginDir) + } + assertTrue(nonPluginEx.message?.contains("Refusing to purge non-plugin directory") == true) + + val userHome = System.getProperty("user.home")?.let { File(it) } + if (userHome != null && userHome.exists()) { + val homeEx = + assertFailsWith { + PluginScaffolder.assertSafeToPurge(userHome) + } + assertTrue(homeEx.message?.contains("user home") == true) + } + } + + @Test + fun `scaffolded manifest passes host PluginManifestReader parse and validation across all templates`() { + val templates = listOf("mcp-tool", "ui-panel", "background-service", "full") + for (tmpl in templates) { + val targetFolder = File(tempDir.toFile(), "manifest-verify-$tmpl") + PluginScaffolder.scaffold( + name = "test-$tmpl", + templateName = tmpl, + targetDir = targetFolder, + ) + val manifestFile = File(targetFolder, "plugin.json") + assertTrue(manifestFile.exists(), "Manifest should exist for $tmpl") + val hostManifest = PluginManifestReader.parseManifest(manifestFile.readText()) + PluginManifestReader.validateManifest(hostManifest) + assertEquals("com.example.test-$tmpl", hostManifest.pluginId) + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginValidatorEvalTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginValidatorEvalTest.kt new file mode 100644 index 0000000000..879cfb1538 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginValidatorEvalTest.kt @@ -0,0 +1,523 @@ +package ai.rever.boss.cli.plugin + +import ai.rever.boss.plugin.launchpad.DevPluginArtifacts +import ai.rever.boss.plugin.launchpad.HostMeta +import ai.rever.boss.plugin.launchpad.PluginManifest +import ai.rever.boss.plugin.launchpad.PluginValidator +import ai.rever.boss.plugin.launchpad.launchpadJson +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.io.FileOutputStream +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PluginValidatorEvalTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `tests valid directory passes validation`() { + val dir = File(tempDir.toFile(), "valid-dir") + dir.mkdirs() + + val manifest = + PluginManifest( + pluginId = "com.example.valid-plugin", + displayName = "Valid Plugin", + version = "1.0.0", + description = "A valid test plugin", + author = "Test Author", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.ValidPlugin", + requiredPermissions = listOf("mcp", "terminal"), + ) + File(dir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + + val result = PluginValidator.validate(dir) + assertTrue(result.isValid, "Valid directory should pass validation") + assertTrue(result.checks.all { it.passed }) + } + + @Test + fun `tests missing plugin json fails with exact diagnostic`() { + val emptyDir = File(tempDir.toFile(), "missing-manifest-dir") + emptyDir.mkdirs() + + val result = PluginValidator.validate(emptyDir) + assertFalse(result.isValid, "Directory without plugin.json must fail validation") + + val manifestCheck = result.checks.firstOrNull { it.name == "manifest-exists" } + assertNotNull(manifestCheck) + assertFalse(manifestCheck.passed) + assertTrue(manifestCheck.message.contains("plugin.json not found in directory")) + } + + @Test + fun `tests corrupt JSON fails cleanly without uncaught exception`() { + val corruptDir = File(tempDir.toFile(), "corrupt-json-dir") + corruptDir.mkdirs() + File(corruptDir, "plugin.json").writeText("{ id: 'broken', unclosed: ") + + val result = PluginValidator.validate(corruptDir) + assertFalse(result.isValid, "Corrupt JSON must fail validation") + + val jsonCheck = result.checks.firstOrNull { it.name == "manifest-json-valid" } + assertNotNull(jsonCheck) + assertFalse(jsonCheck.passed) + assertTrue(jsonCheck.message.contains("Malformed plugin.json")) + } + + @Test + fun `tests incompatible minApiVersion 999 fails`() { + val incompatibleDir = File(tempDir.toFile(), "incompatible-api-dir") + incompatibleDir.mkdirs() + + val manifest = + PluginManifest( + pluginId = "com.example.future-plugin", + displayName = "Future Plugin", + version = "1.0.0", + description = "Needs future API", + author = "Time Traveler", + apiVersion = "999.0.0", + mainClass = "com.example.FuturePlugin", + ) + File(incompatibleDir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + + val result = PluginValidator.validate(incompatibleDir) + assertFalse(result.isValid, "minApiVersion 999.0.0 must fail validation") + + val apiCheck = result.checks.firstOrNull { it.name == "min-api-version" } + assertNotNull(apiCheck) + assertFalse(apiCheck.passed) + assertTrue(apiCheck.message.contains("incompatible or exceeds host API version")) + } + + @Test + fun `tests synthetic JAR with missing class entry fails bytecode verification`() { + val jarFile = File(tempDir.toFile(), "missing-class.jar") + val manifest = + PluginManifest( + pluginId = "com.example.test-jar-plugin", + displayName = "Test Jar Plugin", + version = "0.2.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.MissingClass", + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + + // Create JAR with only plugin.json in META-INF/boss-plugin/, missing bytecode entry + createJar(jarFile, mapOf("META-INF/boss-plugin/plugin.json" to manifestBytes)) + + val result = PluginValidator.validate(jarFile) + assertFalse(result.isValid, "JAR missing bytecode entry must fail validation") + + val bytecodeCheck = result.checks.firstOrNull { it.name == "bytecode-entrypoint" } + assertNotNull(bytecodeCheck) + assertFalse(bytecodeCheck.passed) + val expectedMsg = "Bytecode entry 'com/example/MissingClass.class' not found in archive" + assertTrue(bytecodeCheck.message.contains(expectedMsg)) + } + + @Test + fun `tests synthetic JAR with present class entry passes bytecode verification`() { + val jarFile = File(tempDir.toFile(), "valid-class.jar") + val manifest = + PluginManifest( + pluginId = "com.example.test-jar-plugin", + displayName = "Test Jar Plugin", + version = "0.2.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = ValidatorTestFixturePlugin::class.java.name, + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + val classEntryPath = ValidatorTestFixturePlugin::class.java.name.replace('.', '/') + ".class" + val realClassBytes = + ValidatorTestFixturePlugin::class.java.classLoader + .getResourceAsStream(classEntryPath)!! + .readBytes() + + // Create JAR with plugin.json and real class bytes + createJar( + jarFile, + mapOf( + "META-INF/boss-plugin/plugin.json" to manifestBytes, + classEntryPath to realClassBytes, + ), + ) + + val result = PluginValidator.validate(jarFile) + val failedMessages = result.checks.filter { !it.passed }.map { it.message } + assertTrue( + result.isValid, + "JAR with present bytecode entry must pass validation: $failedMessages", + ) + + val bytecodeCheck = result.checks.firstOrNull { it.name == "bytecode-entrypoint" } + assertNotNull(bytecodeCheck) + assertTrue(bytecodeCheck.passed) + assertTrue(bytecodeCheck.message.contains("verified in archive")) + + val implementsCheck = result.checks.firstOrNull { it.name == "bytecode-implements-plugin" } + assertNotNull(implementsCheck) + assertTrue(implementsCheck.passed) + } + + @Test + fun `tests synthetic JAR with class not implementing Plugin fails bytecode verification`() { + val jarFile = File(tempDir.toFile(), "non-plugin-class.jar") + val manifest = + PluginManifest( + pluginId = "com.example.non-plugin", + displayName = "Non Plugin", + version = "0.2.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = NonPluginFixture::class.java.name, + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + val classEntryPath = NonPluginFixture::class.java.name.replace('.', '/') + ".class" + val realClassBytes = + NonPluginFixture::class.java.classLoader + .getResourceAsStream(classEntryPath)!! + .readBytes() + + createJar( + jarFile, + mapOf( + "META-INF/boss-plugin/plugin.json" to manifestBytes, + classEntryPath to realClassBytes, + ), + ) + + val result = PluginValidator.validate(jarFile) + assertFalse(result.isValid, "JAR with non-Plugin class must fail validation") + + val implementsCheck = result.checks.firstOrNull { it.name == "bytecode-implements-plugin" } + assertNotNull(implementsCheck) + assertFalse(implementsCheck.passed) + assertTrue(implementsCheck.message.contains("must implement ai.rever.boss.plugin.api.Plugin")) + } + + @Test + fun `tests reverse-domain dotted IDs pass validation`() { + val dir = File(tempDir.toFile(), "dotted-id-dir") + dir.mkdirs() + val manifest = + PluginManifest( + pluginId = "com.example.my.awesome.tool", + displayName = "Awesome Tool", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.AwesomeTool", + ) + File(dir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + val result = PluginValidator.validate(dir) + val idCheck = result.checks.firstOrNull { it.name == "id-format" } + assertNotNull(idCheck) + assertTrue(idCheck.passed, "com.example.my.awesome.tool must pass id-format check") + } + + @Test + fun `tests directory with resource manifest passes validation`() { + val dir = File(tempDir.toFile(), "resource-manifest-dir") + val metaInf = File(dir, "src/main/resources/META-INF/boss-plugin").apply { mkdirs() } + val manifest = + PluginManifest( + pluginId = "com.example.resource.plugin", + displayName = "Resource Plugin", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.ResourcePlugin", + ) + File(metaInf, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + val result = PluginValidator.validate(dir) + assertTrue(result.isValid, "Directory with resource manifest must pass validation") + } + + @Test + fun `tests SemVer 2 pre-release and build metadata pass validation`() { + val semVerCases = listOf("1.0.0-alpha.1", "0.2.0-rc.3", "0.1.0+build.2026") + for (versionStr in semVerCases) { + val dir = File(tempDir.toFile(), "semver-${versionStr.replace(Regex("[^a-zA-Z0-9]"), "-")}") + dir.mkdirs() + val manifest = + PluginManifest( + pluginId = "com.example.semver-plugin", + displayName = "SemVer Plugin", + version = versionStr, + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.ValidPlugin", + ) + File(dir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + val result = PluginValidator.validate(dir) + val versionCheck = result.checks.firstOrNull { it.name == "version-format" } + assertNotNull(versionCheck, "version-format check missing for $versionStr") + assertTrue(versionCheck.passed, "SemVer string '$versionStr' must validate as true") + } + } + + @Test + fun `tests canonical permissions vocabulary validation`() { + val dir = File(tempDir.toFile(), "canonical-perms-dir") + dir.mkdirs() + val allCanonical = + listOf( + "network", + "filesystem", + "terminal", + "browser", + "notifications", + "auth", + "mcp", + "editor", + "clipboard", + "settings", + "system", + "storage", + ) + val manifest = + PluginManifest( + pluginId = "com.example.perms-plugin", + displayName = "Permissions Plugin", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.ValidPlugin", + requiredPermissions = allCanonical, + ) + File(dir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + + val result = PluginValidator.validate(dir) + val permsCheck = result.checks.firstOrNull { it.name == "permissions" } + assertNotNull(permsCheck) + assertTrue(permsCheck.passed, "All canonical permissions must pass") + + // Test real RBAC permissions (plugins.create, secret.read) + val rbacDir = File(tempDir.toFile(), "rbac-perms-dir") + rbacDir.mkdirs() + val rbacManifest = + manifest.copy(requiredPermissions = listOf("plugins.create", "secret.read", "api_key.create")) + File(rbacDir, "plugin.json").writeText(launchpadJson.encodeToString(rbacManifest)) + val rbacResult = PluginValidator.validate(rbacDir) + val rbacCheck = rbacResult.checks.firstOrNull { it.name == "permissions" } + assertNotNull(rbacCheck) + assertTrue(rbacCheck.passed, "Real host RBAC permissions must pass validation") + + // Test invalid permission format (spaces/special characters) + val invalidDir = File(tempDir.toFile(), "invalid-perms-dir") + invalidDir.mkdirs() + val invalidManifest = manifest.copy(requiredPermissions = listOf("invalid permission with spaces!")) + File(invalidDir, "plugin.json").writeText(launchpadJson.encodeToString(invalidManifest)) + val invalidResult = PluginValidator.validate(invalidDir) + val invalidCheck = invalidResult.checks.firstOrNull { it.name == "permissions" } + assertNotNull(invalidCheck) + assertFalse(invalidCheck.passed, "Malformed permission identifier must fail") + } + + @Test + fun `tests dotless plugin ID fails id-format check`() { + val dir = File(tempDir.toFile(), "dotless-id-dir") + dir.mkdirs() + val manifest = + PluginManifest( + pluginId = "dotless-plugin", + displayName = "Dotless Plugin", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.DotlessPlugin", + ) + File(dir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + val result = PluginValidator.validate(dir) + assertFalse(result.isValid, "Dotless plugin ID must fail validation") + val idCheck = result.checks.firstOrNull { it.name == "id-format" } + assertNotNull(idCheck) + assertFalse(idCheck.passed, "Dotless plugin ID must fail id-format check") + assertTrue(idCheck.message.contains("must follow reverse domain notation")) + } + + @Test + fun `tests JAR with root plugin json is rejected requiring META-INF path`() { + val jarFile = File(tempDir.toFile(), "root-manifest-only.jar") + val manifest = + PluginManifest( + pluginId = "com.example.root-manifest-plugin", + displayName = "Root Manifest Plugin", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.SomeClass", + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + createJar( + jarFile, + mapOf("plugin.json" to manifestBytes), + ) + + val result = PluginValidator.validate(jarFile) + assertFalse(result.isValid, "JAR with root plugin.json must fail validation") + val manifestCheck = result.checks.firstOrNull { it.name == "manifest-exists" } + assertNotNull(manifestCheck) + assertFalse(manifestCheck.passed) + assertTrue( + manifestCheck.message.contains("must be located at META-INF/boss-plugin/plugin.json"), + "Expected diagnostic pointing to META-INF location, got: ${manifestCheck.message}", + ) + } + + @Test + fun `tests closed archive does not crash bytecode fallback parser`() { + val jarFile = File(tempDir.toFile(), "closed-archive-fallback.jar") + val unresolvableClassName = "com.example.synthetic.UnresolvablePlugin" + val manifest = + PluginManifest( + pluginId = "com.example.fallback-plugin", + displayName = "Fallback Plugin", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = unresolvableClassName, + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + val classEntryPath = unresolvableClassName.replace('.', '/') + ".class" + // Class bytes inherit from a missing superclass absent from the JAR and classpath, + // forcing Class.forName to throw NoClassDefFoundError and exercising the manual bytecode parser. + val syntheticClassBytes = + generateSyntheticPluginClassBytes( + className = unresolvableClassName.replace('.', '/'), + superClassName = "com/example/missing/MissingSuperClass", + interfaceName = "ai/rever/boss/plugin/api/Plugin", + ) + + createJar( + jarFile, + mapOf( + "META-INF/boss-plugin/plugin.json" to manifestBytes, + classEntryPath to syntheticClassBytes, + ), + ) + + val result = PluginValidator.validate(jarFile) + assertTrue(result.isValid, "Valid JAR must pass validation without closed zip errors") + val implementsCheck = result.checks.firstOrNull { it.name == "bytecode-implements-plugin" } + assertNotNull(implementsCheck) + assertTrue( + implementsCheck.passed, + "Fallback bytecode parser must detect direct Plugin interface implementation", + ) + } + + @Test + fun `tests truncated classfile bytes returns false without throwing BufferUnderflowException`() { + val truncatedBytes = + byteArrayOf(0xCA.toByte(), 0xFE.toByte(), 0xBA.toByte(), 0xBE.toByte(), 0, 0, 0, 52, 0, 2) + assertFalse( + PluginValidator.checkDirectInterfaceImplementation(truncatedBytes), + "Truncated bytecode must safely return false and not throw BufferUnderflowException", + ) + } + + @Test + fun `manifest exceeding 512 KB limit fails validation safely`() { + val jarFile = File(tempDir.toFile(), "oversized-manifest.jar") + val padding = "x".repeat(525 * 1024) + val oversizedJson = """{"pluginId":"com.example.big","version":"1.0.0","description":"$padding"}""" + createJar( + jarFile, + mapOf("META-INF/boss-plugin/plugin.json" to oversizedJson.toByteArray(StandardCharsets.UTF_8)), + ) + + val result = PluginValidator.validate(jarFile) + assertFalse(result.isValid, "JAR with manifest > 512 KB must fail validation") + val manifestCheck = result.checks.firstOrNull { it.name == "manifest-valid" } + assertNotNull(manifestCheck, "Must record manifest-valid failure check") + assertFalse(manifestCheck.passed) + assertTrue(manifestCheck.message.contains("512 KB")) + + val manifest = PluginValidator.readManifestFromJar(jarFile) + assertNull(manifest, "readManifestFromJar must return null for manifest exceeding limit") + assertFalse( + DevPluginArtifacts.isValidDevJar(jarFile), + "DevPluginArtifacts.isValidDevJar must reject oversized manifest", + ) + } + + private fun generateSyntheticPluginClassBytes( + className: String, + superClassName: String, + interfaceName: String, + ): ByteArray { + val baos = java.io.ByteArrayOutputStream() + val dos = java.io.DataOutputStream(baos) + dos.writeInt(0xCAFEBABE.toInt()) + dos.writeShort(0) // minor + dos.writeShort(52) // major (Java 8) + dos.writeShort(7) // cp count = 7 (indices 1..6) + // 1: Utf8 className + dos.writeByte(1) + dos.writeUTF(className) + // 2: Class className + dos.writeByte(7) + dos.writeShort(1) + // 3: Utf8 superClassName + dos.writeByte(1) + dos.writeUTF(superClassName) + // 4: Class superClassName + dos.writeByte(7) + dos.writeShort(3) + // 5: Utf8 interfaceName + dos.writeByte(1) + dos.writeUTF(interfaceName) + // 6: Class interfaceName + dos.writeByte(7) + dos.writeShort(5) + // access flags + dos.writeShort(0x0021) + // this_class + dos.writeShort(2) + // super_class + dos.writeShort(4) + // interfaces + dos.writeShort(1) + dos.writeShort(6) + // fields + dos.writeShort(0) + // methods + dos.writeShort(0) + // attributes + dos.writeShort(0) + dos.flush() + return baos.toByteArray() + } + + private fun createJar( + jarFile: File, + entries: Map, + ) { + JarOutputStream(FileOutputStream(jarFile)).use { jos -> + for ((name, bytes) in entries) { + val entry = JarEntry(name) + jos.putNextEntry(entry) + jos.write(bytes) + jos.closeEntry() + } + } + } +} + +class ValidatorTestFixturePlugin : ai.rever.boss.plugin.api.Plugin { + override val pluginId = "com.example.test-jar-plugin" + override val displayName = "Test Jar Plugin" + + override fun register(context: ai.rever.boss.plugin.api.PluginContext) { + // No-op for test fixture + } +} + +class NonPluginFixture { + val someProperty = "not a plugin" +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloaderTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloaderTest.kt new file mode 100644 index 0000000000..3f38795aaa --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloaderTest.kt @@ -0,0 +1,193 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.components.plugin.DynamicPluginInfo +import ai.rever.boss.components.plugin.DynamicPluginManager +import ai.rever.boss.plugin.api.PanelRegistry +import ai.rever.boss.plugin.api.PluginState +import ai.rever.boss.plugin.api.TabRegistry +import ai.rever.boss.plugin.sandbox.PluginSandboxManagerImpl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import ai.rever.boss.plugin.api.PluginManifest as ApiPluginManifest + +class DevPluginReloaderTest { + @TempDir + lateinit var tempDir: Path + + private val activeManagersToClean = mutableListOf() + + @BeforeTest + fun setUp() { + DevPluginArtifacts.stagingRootOverride = tempDir.resolve("dev-root").toFile().apply { mkdirs() } + } + + @AfterTest + fun tearDown() { + runBlocking { + activeManagersToClean.forEach { + runCatching { it.disposeWindow() } + } + activeManagersToClean.clear() + } + DevPluginArtifacts.stagingRootOverride = null + } + + private fun createManager(): DynamicPluginManager { + val sandboxManager = PluginSandboxManagerImpl() + val manager = + DynamicPluginManager( + PanelRegistry(), + TabRegistry(), + sandboxManager, + createSandboxedContext = { _, _ -> error("Test fixture context") }, + ) + activeManagersToClean.add(manager) + return manager + } + + @Suppress("UNCHECKED_CAST") + private fun setPluginInfo( + manager: DynamicPluginManager, + pluginId: String, + info: DynamicPluginInfo, + ) { + val field = DynamicPluginManager::class.java.getDeclaredField("_pluginStates").apply { isAccessible = true } + val flow = field.get(manager) as MutableStateFlow> + flow.value = flow.value + (pluginId to info) + } + + @Test + fun `four successive stages across two managers preserves open older jar`() { + val pluginId = "multi-window-plugin" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val pluginDevDir = File(stagingRoot, pluginId).apply { mkdirs() } + + // 1. Window 1 and Window 2 both load v1 (staged at v1000) + // 2. Perform stage + reload to v2 (both managers update to v2000) + // 3. Window 2 holds a lock/handle on v2, while Window 1 performs successive links to v3, v4, and v5 + val timestamps = listOf(1000L, 2000L, 3000L, 4000L, 5000L) + val jarFiles = mutableMapOf() + for (ts in timestamps) { + val vDir = File(pluginDevDir, "v$ts").apply { mkdirs() } + val jar = File(vDir, "$pluginId.jar") + jar.writeText("content-$ts") + jarFiles[ts] = jar + } + + val v2Jar = jarFiles[2000L]!! + val activeJarPaths = setOf(v2Jar.absolutePath) + + // 4. Assert that DevPluginArtifacts.pruneStagingHistory prunes unreferenced versions, + // but strictly retains v2 because Window 2's manager still lists v2 in activeJarPaths. + DevPluginArtifacts.pruneStagingHistory( + pluginDevDir = pluginDevDir, + maxVersionsToKeep = 3, + activeJarPaths = activeJarPaths, + ) + + val remaining = + pluginDevDir + .listFiles { file -> file.isDirectory && file.name.startsWith("v") } + ?.map { it.name } + ?.sorted() + ?: emptyList() + + // v1000 is pruned because it is not active and older than top-3 newest (v3000, v4000, v5000). + // v2000 MUST be retained because Window 2 still holds an active handle on it! + // v3000, v4000, v5000 are the top 3 newest versions. + assertEquals( + listOf("v2000", "v3000", "v4000", "v5000"), + remaining, + "v2000 must be retained because it is actively referenced by Window 2", + ) + } + + @Test + fun `two managers dry-run pre-flight check aborts if one manager has protected plugin`() = + runBlocking { + val pluginId = "protected-test-plugin" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val vDir = File(stagingRoot, "$pluginId/v1000").apply { mkdirs() } + File(vDir, "$pluginId.jar").writeText("valid-dev-jar") + + val manager1 = createManager() + val manager2 = createManager() + + // Manager 1 can unload + val info1 = + DynamicPluginInfo( + manifest = + ApiPluginManifest( + pluginId = pluginId, + displayName = "Test Plugin", + version = "1.0.0", + apiVersion = "1.0.0", + mainClass = "test.Main", + canUnload = true, + ), + jarPath = File(vDir, "$pluginId.jar").absolutePath, + state = PluginState.LOADED, + loadedAt = System.currentTimeMillis(), + enabled = true, + ) + setPluginInfo(manager1, pluginId, info1) + + // Manager 2 cannot unload (system protected / canUnload=false) + val info2 = + DynamicPluginInfo( + manifest = + ApiPluginManifest( + pluginId = pluginId, + displayName = "Test Plugin", + version = "1.0.0", + apiVersion = "1.0.0", + mainClass = "test.Main", + canUnload = false, + ), + jarPath = File(vDir, "$pluginId.jar").absolutePath, + state = PluginState.LOADED, + loadedAt = System.currentTimeMillis(), + enabled = true, + ) + setPluginInfo(manager2, pluginId, info2) + + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Pre-flight check must abort reload across all managers") + val exception = result.exceptionOrNull() + assertTrue( + exception?.message?.contains("protected system plugin") == true, + "Expected protected plugin error message, got: ${exception?.message}", + ) + + // Manager 1's plugin state must remain untouched (never unloaded) + assertEquals(PluginState.LOADED, manager1.getPluginInfo(pluginId)?.state) + assertEquals(PluginState.LOADED, manager2.getPluginInfo(pluginId)?.state) + } + + @Test + fun `reload fails when no host manager is initialized`() = + runBlocking { + val result = DevPluginReloader.reload("non-existent-plugin") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("not yet initialized") == true) + } + + @Test + fun `reload fails when staged dev JAR is missing`() = + runBlocking { + createManager() + val result = DevPluginReloader.reload("missing-jar-plugin") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("No staged dev JAR found") == true) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginRollbackTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginRollbackTest.kt new file mode 100644 index 0000000000..30739b3cb3 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginRollbackTest.kt @@ -0,0 +1,947 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.cli.plugin.ValidatorTestFixturePlugin +import ai.rever.boss.components.plugin.DefaultPlugin +import ai.rever.boss.components.plugin.DynamicPluginInfo +import ai.rever.boss.components.plugin.DynamicPluginManager +import ai.rever.boss.plugin.PluginPersistence +import ai.rever.boss.plugin.PluginStoreSetup +import ai.rever.boss.plugin.api.CanUnloadResult +import ai.rever.boss.plugin.api.PanelRegistry +import ai.rever.boss.plugin.api.PluginContext +import ai.rever.boss.plugin.api.PluginState +import ai.rever.boss.plugin.api.PluginUnloadAware +import ai.rever.boss.plugin.api.TabRegistry +import ai.rever.boss.plugin.sandbox.PluginSandboxManagerImpl +import ai.rever.boss.plugin.sandbox.SandboxConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Path +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Suppress("LargeClass") +class DevPluginRollbackTest { + @TempDir + lateinit var tempDir: Path + + private val activeManagersToClean = mutableListOf() + + @BeforeTest + fun setUp() { + DevPluginArtifacts.stagingRootOverride = tempDir.resolve("dev-root").toFile().apply { mkdirs() } + DevPluginReloader.clearSessionPreservedPathsForTest() + } + + @AfterTest + fun tearDown() { + runBlocking { + activeManagersToClean.forEach { + runCatching { it.disposeWindow() } + } + activeManagersToClean.clear() + } + DevPluginArtifacts.stagingRootOverride = null + DevPluginReloader.clearSessionPreservedPathsForTest() + } + + private class TestPluginContext( + override val panelRegistry: PanelRegistry = PanelRegistry(), + override val tabRegistry: TabRegistry = TabRegistry(), + override val pluginScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + ) : PluginContext + + private fun createManager( + onSandboxedContext: ((pluginId: String, config: SandboxConfig) -> PluginContext)? = null, + ): DynamicPluginManager { + val sandboxManager = PluginSandboxManagerImpl() + val dummyContext = TestPluginContext() + val manager = + DynamicPluginManager( + dummyContext.panelRegistry, + dummyContext.tabRegistry, + sandboxManager, + createSandboxedContext = { pluginId, config -> + onSandboxedContext?.invoke(pluginId, config) ?: dummyContext + }, + ) + activeManagersToClean.add(manager) + return manager + } + + private fun createJar( + file: File, + entries: Map, + ) { + file.parentFile?.mkdirs() + JarOutputStream(FileOutputStream(file)).use { jos -> + entries.forEach { (name, bytes) -> + jos.putNextEntry(JarEntry(name)) + jos.write(bytes) + } + } + } + + private fun createDevTestJar( + stagingRoot: File, + pluginId: String, + versionDir: String, + version: String, + mainClass: String = ValidatorTestFixturePlugin::class.java.name, + includeMainClassBytecode: Boolean = true, + ): File { + val dir = File(stagingRoot, "$pluginId/$versionDir").apply { mkdirs() } + val jar = File(dir, "$pluginId.jar") + val manifest = + """ + { + "manifestVersion": 1, + "pluginId": "$pluginId", + "displayName": "Hot Reload Tool $version", + "version": "$version", + "apiVersion": "1.0.0", + "mainClass": "$mainClass" + } + """.trimIndent().toByteArray(Charsets.UTF_8) + val entries = + mutableMapOf( + "META-INF/boss-plugin/plugin.json" to manifest, + ) + if (includeMainClassBytecode) { + val classEntryPath = mainClass.replace('.', '/') + ".class" + val classBytes = + ValidatorTestFixturePlugin::class.java.classLoader + .getResourceAsStream(classEntryPath)!! + .readBytes() + entries[classEntryPath] = classBytes + } + createJar(jar, entries) + return jar + } + + private fun createStoreTestJar( + storeJar: File, + pluginId: String, + version: String = "1.0.0", + ) { + val classEntryPath = ValidatorTestFixturePlugin::class.java.name.replace('.', '/') + ".class" + val classBytes = + ValidatorTestFixturePlugin::class.java.classLoader + .getResourceAsStream(classEntryPath)!! + .readBytes() + val storeManifest = + """ + { + "manifestVersion": 1, + "pluginId": "$pluginId", + "displayName": "Store Build $version", + "version": "$version", + "apiVersion": "1.0.0", + "mainClass": "${ValidatorTestFixturePlugin::class.java.name}" + } + """.trimIndent().toByteArray(Charsets.UTF_8) + createJar( + storeJar, + mapOf( + "META-INF/boss-plugin/plugin.json" to storeManifest, + classEntryPath to classBytes, + ), + ) + } + + private fun assertRestoredToV1( + manager: DynamicPluginManager, + pluginId: String, + expectedJar: File, + ) { + val restored = manager.getPluginInfo(pluginId) + assertNotNull(restored, "Manager must have a restored plugin after rollback") + assertEquals( + expectedJar.canonicalPath, + File(restored.jarPath).canonicalPath, + "Manager must have restored to v1.jar", + ) + assertEquals(PluginState.LOADED, restored.state, "Restored plugin must be LOADED") + assertTrue(restored.enabled, "Restored plugin must be enabled") + assertEquals("1.0.0", restored.manifest.version, "Must be on v1 (1.0.0)") + } + + private fun assertRestoredToV1Disabled( + manager: DynamicPluginManager, + pluginId: String, + expectedJar: File, + ) { + val restored = manager.getPluginInfo(pluginId) + assertNotNull(restored, "Manager must have a restored plugin after rollback") + assertEquals( + expectedJar.canonicalPath, + File(restored.jarPath).canonicalPath, + "Manager must have restored to v1.jar", + ) + assertEquals(PluginState.DISABLED, restored.state, "Restored plugin must be DISABLED") + assertFalse(restored.enabled, "Restored plugin must be disabled") + assertEquals("1.0.0", restored.manifest.version, "Must be on v1 (1.0.0)") + } + + @Test + fun `first link failure uninstalls partially installed plugin across managers to restore clean initial state`() = + runBlocking { + val pluginId = "first-link-tool" + val stagingRoot = DevPluginArtifacts.stagingRoot() + createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var manager2FailLink = true + val manager2 = + createManager { _, _ -> + if (manager2FailLink) { + manager2FailLink = false + error("Simulated link failure in manager 2") + } + dummyContext + } + + assertNull(manager1.getPluginInfo(pluginId)) + assertNull(manager2.getPluginInfo(pluginId)) + + // Drive through public DevPluginReloader.reload + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail when second manager fails install") + + assertNull( + manager1.getPluginInfo(pluginId), + "Manager 1 must have uninstalled the newly linked plugin after rollback", + ) + assertNull( + manager2.getPluginInfo(pluginId), + "Manager 2 must remain in clean state without plugin", + ) + } + + @Test + fun `hot reload failure in second manager restores prior jar in first manager`() = + runBlocking { + val pluginId = "com.example.hotreload" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var manager2FailV2Install = false + val manager2 = + createManager { _, _ -> + if (manager2FailV2Install) { + manager2FailV2Install = false + error("Simulated v2 install failure on manager 2") + } + dummyContext + } + + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must load v1.jar successfully") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res2.isSuccess, "Manager 2 must load v1.jar successfully") + + assertEquals( + v1Jar.canonicalPath, + File(manager1.getPluginInfo(pluginId)!!.jarPath).canonicalPath, + ) + assertEquals( + v1Jar.canonicalPath, + File(manager2.getPluginInfo(pluginId)!!.jarPath).canonicalPath, + ) + + // Stage v2.jar and arm manager 2 to fail v2 install + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + manager2FailV2Install = true + + // Drive through public DevPluginReloader.reload + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail when manager 2 fails v2 install") + + assertRestoredToV1(manager1, pluginId, v1Jar) + assertRestoredToV1(manager2, pluginId, v1Jar) + } + + @Test + fun `hot reload failure in second manager restores disabled prior jar in first manager with wasEnabled false`() = + runBlocking { + val pluginId = "com.example.disabled.rollback" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var manager2FailV2Install = false + val manager2 = + createManager { _, _ -> + if (manager2FailV2Install) { + manager2FailV2Install = false + error("Simulated v2 install failure on manager 2") + } + dummyContext + } + + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = false) + assertTrue(res1.isSuccess, "Manager 1 must load v1.jar successfully") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = false) + assertTrue(res2.isSuccess, "Manager 2 must load v1.jar successfully") + + assertEquals(PluginState.DISABLED, manager1.getPluginInfo(pluginId)!!.state) + assertFalse(manager1.getPluginInfo(pluginId)!!.enabled) + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + manager2FailV2Install = true + + // Drive through public DevPluginReloader.reload + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail when manager 2 fails v2 install") + + assertRestoredToV1Disabled(manager1, pluginId, v1Jar) + assertRestoredToV1Disabled(manager2, pluginId, v1Jar) + } + + @Test + fun `reload with late refusal leaves refusing manager untouched`() = + runBlocking { + val pluginId = "com.example.laterefusal" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val manager1 = createManager() + val manager2 = createManager() + + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must install v1.jar") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res2.isSuccess, "Manager 2 must install v1.jar") + + val initialManager2Info = manager2.getPluginInfo(pluginId) + assertNotNull(initialManager2Info) + + var manager1Unloaded = false + manager1.registerUnloadAware( + object : PluginUnloadAware { + override fun checkCanUnload(pluginId: String): CanUnloadResult = CanUnloadResult.Ok + + override fun prepareForUnload(pluginId: String) { + manager1Unloaded = true + } + }, + ) + manager2.registerUnloadAware( + object : PluginUnloadAware { + override fun checkCanUnload(pluginId: String): CanUnloadResult = + if (!manager1Unloaded) { + CanUnloadResult.Ok + } else { + CanUnloadResult.NotAllowed(listOf("Late refusal from manager 2")) + } + + override fun prepareForUnload(pluginId: String) { + // No preparation needed; manager doesn't hold unloadable resources + } + }, + ) + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail when manager 2 refuses unload") + + // Manager 1 had unloaded, so rollback cleanly restores its prior v1.jar + assertRestoredToV1(manager1, pluginId, v1Jar) + + // Manager 2 refused unload, so it was never modified; rollback leaves it completely untouched + assertRestoredToV1(manager2, pluginId, v1Jar) + val currentManager2Info = manager2.getPluginInfo(pluginId) + assertNotNull(currentManager2Info) + assertEquals( + initialManager2Info.loadedAt, + currentManager2Info.loadedAt, + "Manager 2 was untouched and must retain its exact original loaded instance", + ) + } + + @Test + fun `startup fallback loads store jar when staged dev jar fails`() = + runBlocking { + val pluginId = "com.example.startup.fallback" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val storeDir = tempDir.resolve("store-builds").toFile().apply { mkdirs() } + val storeJar = File(storeDir, "$pluginId.jar") + createStoreTestJar(storeJar, pluginId, "1.0.0") + + // Create a dev jar that passes archive and manifest validation, but points to a non-existent mainClass + createDevTestJar( + stagingRoot = stagingRoot, + pluginId = pluginId, + versionDir = "v2000", + version = "2.0.0", + mainClass = "non.existent.BrokenPluginMain", + includeMainClassBytecode = false, + ) + + val manager = createManager() + val storeEntry = + PluginPersistence.InstalledPluginEntry( + pluginId = pluginId, + jarPath = storeJar.absolutePath, + enabled = true, + ) + + val results = + PluginStoreSetup.loadPersistedPluginEntries( + dynamicPluginManager = manager, + persistedPlugins = listOf(storeEntry), + devRoot = stagingRoot, + ) + + val pluginResult = results[pluginId] + assertNotNull(pluginResult, "Must have a result for plugin") + assertTrue( + pluginResult.isSuccess, + "Plugin must successfully fall back to store build: ${pluginResult.exceptionOrNull()}", + ) + + val loadedInfo = manager.getPluginInfo(pluginId) + assertNotNull(loadedInfo, "Plugin must be loaded in manager") + assertEquals(storeJar.canonicalPath, File(loadedInfo.jarPath).canonicalPath, "Must load store jar path") + assertEquals(PluginState.LOADED, loadedInfo.state, "Fallback plugin must be LOADED") + assertEquals("1.0.0", loadedInfo.manifest.version, "Must be on store version 1.0.0") + } + + @Test + fun `reload of disabled plugin preserves disabled state across managers without throwing`() = + runBlocking { + val pluginId = "com.example.disabled.reload" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val manager1 = createManager() + val manager2 = createManager() + + // 1. Both managers start with v1.jar installed and DISABLED + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = false) + assertTrue(res1.isSuccess, "Manager 1 must install v1.jar") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = false) + assertTrue(res2.isSuccess, "Manager 2 must install v1.jar") + + assertEquals(PluginState.DISABLED, manager1.getPluginInfo(pluginId)?.state) + assertFalse(manager1.getPluginInfo(pluginId)!!.enabled) + + // 2. Stage v2.jar + val v2Jar = createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + + // 3. Perform full DevPluginReloader.reload + val reloadResult = DevPluginReloader.reload(pluginId, stagingRoot) + val errorMsg = reloadResult.exceptionOrNull()?.message + assertTrue(reloadResult.isSuccess, "Reload of disabled plugin must succeed: $errorMsg") + + // 4. Verify both managers updated to v2.jar and remain DISABLED + val info1 = manager1.getPluginInfo(pluginId) + assertNotNull(info1) + assertEquals(File(v2Jar.absolutePath).canonicalPath, File(info1.jarPath).canonicalPath) + assertEquals(PluginState.DISABLED, info1.state) + assertFalse(info1.enabled, "Manager 1 must preserve disabled state") + assertEquals("2.0.0", info1.manifest.version) + + val info2 = manager2.getPluginInfo(pluginId) + assertNotNull(info2) + assertEquals(File(v2Jar.absolutePath).canonicalPath, File(info2.jarPath).canonicalPath) + assertEquals(PluginState.DISABLED, info2.state) + assertFalse(info2.enabled, "Manager 2 must preserve disabled state") + assertEquals("2.0.0", info2.manifest.version) + } + + @Test + fun `isValidDevJar validates readable zip and manifest content`() { + val validJar = tempDir.resolve("valid.jar").toFile() + val manifestJson = """{"id": "test-plugin", "version": "1.0.0"}""".toByteArray() + createJar(validJar, mapOf("META-INF/boss-plugin/plugin.json" to manifestJson)) + + assertTrue(DevPluginArtifacts.isValidDevJar(validJar, "test-plugin")) + assertTrue(DevPluginArtifacts.isValidDevJar(validJar, null)) + assertFalse(DevPluginArtifacts.isValidDevJar(validJar, "mismatched-id")) + + // Corrupt zip (random bytes) + val corruptJar = tempDir.resolve("corrupt.jar").toFile() + corruptJar.writeBytes(byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04)) + assertFalse(DevPluginArtifacts.isValidDevJar(corruptJar)) + + // Empty file + val emptyJar = tempDir.resolve("empty.jar").toFile() + emptyJar.writeBytes(byteArrayOf()) + assertFalse(DevPluginArtifacts.isValidDevJar(emptyJar)) + + // Missing manifest entry + val noManifestJar = tempDir.resolve("no-manifest.jar").toFile() + createJar(noManifestJar, mapOf("dummy.txt" to "hello".toByteArray())) + assertFalse(DevPluginArtifacts.isValidDevJar(noManifestJar)) + + // Part file + val partJar = tempDir.resolve("plugin.jar.part").toFile() + createJar(partJar, mapOf("META-INF/boss-plugin/plugin.json" to manifestJson)) + assertFalse(DevPluginArtifacts.isValidDevJar(partJar)) + } + + @Test + fun `findAllActiveDevJars with deepValidate filters out invalid dev jars`() { + val devRoot = tempDir.resolve("staging-test").toFile().apply { mkdirs() } + + // Corrupt dev jar under plugin-corrupt + val corruptDir = File(devRoot, "plugin-corrupt/v1000").apply { mkdirs() } + File(corruptDir, "plugin-corrupt.jar").writeBytes(byteArrayOf(1, 2, 3, 4, 5)) + + // Valid dev jar under plugin-valid + val validDir = File(devRoot, "plugin-valid/v2000").apply { mkdirs() } + val manifestBytes = """{"id": "plugin-valid", "version": "1.0.0"}""".toByteArray() + val validJar = File(validDir, "plugin-valid.jar") + createJar(validJar, mapOf("META-INF/boss-plugin/plugin.json" to manifestBytes)) + + val allDiscovered = DevPluginArtifacts.findAllActiveDevJars(devRoot, deepValidate = true) + assertEquals(1, allDiscovered.size) + assertEquals(validJar.absolutePath, allDiscovered.first().absolutePath) + } + + @Test + fun `partial install failure on window 1 leaves window 2 untouched`() = + runBlocking { + val pluginId = "com.example.partial.install" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val dummyContext = TestPluginContext() + var manager1FailInstall = false + val manager1 = + createManager { _, _ -> + if (manager1FailInstall) { + manager1FailInstall = false + error("Simulated v2 install failure on manager 1") + } + dummyContext + } + val manager2 = createManager() + + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must install v1.jar") + assertNull(manager2.getPluginInfo(pluginId), "Manager 2 must have no plugin") + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + manager1FailInstall = true + + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail when manager 1 fails install") + + assertRestoredToV1(manager1, pluginId, v1Jar) + assertNull(manager2.getPluginInfo(pluginId), "Manager 2 was untouched and remains null") + } + + @Test + fun `startup fallback recovers store jar on binary incompatibility`() = + runBlocking { + val pluginId = "com.example.binary.compat" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val storeDir = tempDir.resolve("store-compat").toFile().apply { mkdirs() } + val storeJar = File(storeDir, "$pluginId.jar") + createStoreTestJar(storeJar, pluginId, "1.0.0") + + createDevTestJar( + stagingRoot = stagingRoot, + pluginId = pluginId, + versionDir = "v2000", + version = "2.0.0", + mainClass = IncompatibleBinaryPlugin::class.java.name, + includeMainClassBytecode = true, + ) + + val manager = createManager() + val storeEntry = + PluginPersistence.InstalledPluginEntry( + pluginId = pluginId, + jarPath = storeJar.absolutePath, + enabled = true, + ) + + val results = + PluginStoreSetup.loadPersistedPluginEntries( + dynamicPluginManager = manager, + persistedPlugins = listOf(storeEntry), + devRoot = stagingRoot, + ) + + val pluginResult = results[pluginId] + assertNotNull(pluginResult, "Must have a result for plugin") + assertTrue( + pluginResult.isSuccess, + "Plugin must fall back to store build on binary incompatibility: ${pluginResult.exceptionOrNull()}", + ) + + val loadedInfo = manager.getPluginInfo(pluginId) + assertNotNull(loadedInfo, "Plugin must be loaded in manager") + assertEquals(storeJar.canonicalPath, File(loadedInfo.jarPath).canonicalPath) + assertEquals(PluginState.LOADED, loadedInfo.state) + assertEquals("1.0.0", loadedInfo.manifest.version) + } + + @Test + fun `persisted startup keeps the installed build for a protected prefix id`() { + val pluginId = "ai.rever.boss.system.fixture" + val stagingRoot = DevPluginArtifacts.stagingRoot() + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + val storeJar = tempDir.resolve("protected-store.jar").toFile() + createStoreTestJar(storeJar, pluginId, "1.0.0") + val entry = PluginPersistence.InstalledPluginEntry(pluginId, storeJar.absolutePath, enabled = true) + + assertEquals(storeJar.absolutePath, PluginStoreSetup.resolvePersistedEntryPath(entry, stagingRoot)) + } + + @Test + fun `external scan falls back to standard jar on dev failure`() = + runBlocking { + val pluginId = "com.example.ext.fallback" + val pluginDir = tempDir.resolve("ext-plugins").toFile().apply { mkdirs() } + val standardJar = File(pluginDir, "$pluginId.jar") + createStoreTestJar(standardJar, pluginId, "1.0.0") + + val devRoot = File(pluginDir, "dev").apply { mkdirs() } + DevPluginArtifacts.stagingRootOverride = devRoot + try { + val devJar = + createDevTestJar( + stagingRoot = devRoot, + pluginId = pluginId, + versionDir = "v2000", + version = "2.0.0", + mainClass = "non.existent.BrokenClass", + includeMainClassBytecode = false, + ) + + val manager = createManager() + val defaultPlugin = + DefaultPlugin( + panelRegistry = PanelRegistry(), + tabRegistry = TabRegistry(), + windowProjectState = null, + ) + + defaultPlugin.installSingleExternalPlugin( + manager = manager, + jarFile = devJar, + trackedJarPaths = emptySet(), + fallbackStandardJar = standardJar, + ) + + val loaded = manager.getPluginInfo(pluginId) + assertNotNull(loaded, "Manager must load fallback standard plugin") + assertEquals(standardJar.canonicalPath, File(loaded.jarPath).canonicalPath) + assertEquals(PluginState.LOADED, loaded.state) + assertEquals("1.0.0", loaded.manifest.version) + } finally { + DevPluginArtifacts.stagingRootOverride = null + } + } + + @Test + fun `failed restoration in rollback attaches suppressed exception`() = + runBlocking { + val pluginId = "com.example.failed.restore" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = + createDevTestJar( + stagingRoot = stagingRoot, + pluginId = pluginId, + versionDir = "v1000", + version = "1.0.0", + mainClass = ControllableFailingPlugin::class.java.name, + includeMainClassBytecode = true, + ) + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var failManager2Install = false + val manager2 = + createManager { _, _ -> + if (failManager2Install) { + failManager2Install = false + error("Simulated v2 install failure on manager 2") + } + dummyContext + } + + System.clearProperty("boss.test.fail_rollback_register") + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must install v1.jar") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res2.isSuccess, "Manager 2 must install v1.jar") + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + failManager2Install = true + System.setProperty("boss.test.fail_rollback_register", "true") + + try { + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail") + val root = result.exceptionOrNull() + assertNotNull(root, "Root exception must be present") + assertTrue( + root.suppressedExceptions.isNotEmpty(), + "Expected recovery failure to be attached as suppressed exception", + ) + assertTrue( + root.suppressedExceptions.any { + it.message?.contains("Failed to reinstall prior JAR") == true + }, + "Suppressed exceptions must contain rollback reinstall failure", + ) + } finally { + System.clearProperty("boss.test.fail_rollback_register") + } + } + + @Test + fun `binary incompatible restoration is reported as rollback failure`() = + runBlocking { + val pluginId = "com.example.failed.restore" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = + createDevTestJar( + stagingRoot = stagingRoot, + pluginId = pluginId, + versionDir = "v1000", + version = "1.0.0", + mainClass = ControllableFailingPlugin::class.java.name, + includeMainClassBytecode = true, + ) + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var failManager2Install = false + val manager2 = + createManager { _, _ -> + if (failManager2Install) { + failManager2Install = false + error("Simulated v2 install failure on manager 2") + } + dummyContext + } + + System.clearProperty("boss.test.fail_rollback_binary") + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must install v1.jar") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res2.isSuccess, "Manager 2 must install v1.jar") + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + failManager2Install = true + System.setProperty("boss.test.fail_rollback_binary", "true") + + try { + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail") + val root = result.exceptionOrNull() + assertNotNull(root, "Root exception must be present") + assertTrue( + root.suppressedExceptions.isNotEmpty(), + "Expected recovery failure to be attached as suppressed exception", + ) + assertTrue( + root.suppressedExceptions.any { + it.message?.contains("Failed to reinstall prior JAR") == true + }, + "Suppressed exceptions must contain rollback reinstall failure", + ) + } finally { + System.clearProperty("boss.test.fail_rollback_binary") + } + } + + @Test + fun `protected dev candidate is dropped before deduplication`() { + val pluginId = ai.rever.boss.components.plugin.MicrokernelRuntime.PLUGIN_ID + val stagingRoot = DevPluginArtifacts.stagingRoot() + val devJar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val deduplicated = + DefaultPlugin.deduplicateJars(listOf(devJar)) { id -> + DefaultPlugin.isAuthoritativeSystemPlugin(id) + } + + assertTrue( + deduplicated.isEmpty(), + "Lone dev JAR claiming authoritative protected ID must be dropped before deduplication", + ) + } + + @Test + fun `repeated reloads preserve session retained jar paths`() = + runBlocking { + val pluginId = "com.example.repeated.retention" + val stagingRoot = DevPluginArtifacts.stagingRoot() + createManager() + + val jars = + (1..5).map { v -> + val jar = createDevTestJar(stagingRoot, pluginId, "v${v}000", "$v.0.0") + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isSuccess, "Reload v$v must succeed") + jar + } + + jars.forEach { jar -> + assertTrue( + jar.exists(), + "Session retained JAR ${jar.name} must not be deleted by repeated reloads", + ) + } + } + + @Test + fun `overlapping reloads on same plugin are serialized by mutex`() = + runBlocking { + val pluginId = "com.example.mutex.reload" + val stagingRoot = DevPluginArtifacts.stagingRoot() + createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val manager = createManager() + val res1 = + manager.installPlugin( + File(stagingRoot, "$pluginId/v1000/$pluginId.jar").absolutePath, + enabled = true, + ) + assertTrue(res1.isSuccess) + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + + val job1 = + async(Dispatchers.Default) { + DevPluginReloader.reload(pluginId, stagingRoot) + } + val job2 = + async(Dispatchers.Default) { + DevPluginReloader.reload(pluginId, stagingRoot) + } + + val result1 = job1.await() + val result2 = job2.await() + + assertTrue(result1.isSuccess, "First concurrent reload must succeed") + assertTrue(result2.isSuccess, "Second concurrent reload must succeed") + assertEquals("2.0.0", manager.getPluginInfo(pluginId)?.manifest?.version) + } + + @Test + fun `failed reload jar is retained across subsequent successful reloads`() = + runBlocking { + DevPluginReloader.clearSessionPreservedPathsForTest() + val pluginId = "com.example.failed.retention" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var manager2FailV2Install = false + val manager2 = + createManager { _, _ -> + if (manager2FailV2Install) { + manager2FailV2Install = false + error("Simulated v2 install failure on manager 2") + } + dummyContext + } + + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must load v1.jar successfully") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res2.isSuccess, "Manager 2 must load v1.jar successfully") + + val v2Jar = createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + manager2FailV2Install = true + + val reloadV2Result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(reloadV2Result.isFailure, "Reload with failing manager 2 must fail") + + assertRestoredToV1(manager1, pluginId, v1Jar) + assertRestoredToV1(manager2, pluginId, v1Jar) + + val v3Jar = createDevTestJar(stagingRoot, pluginId, "v3000", "3.0.0") + val resV3 = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(resV3.isSuccess, "Reload v3 must succeed") + + val v4Jar = createDevTestJar(stagingRoot, pluginId, "v4000", "4.0.0") + val resV4 = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(resV4.isSuccess, "Reload v4 must succeed") + + val v5Jar = createDevTestJar(stagingRoot, pluginId, "v5000", "5.0.0") + val resV5 = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(resV5.isSuccess, "Reload v5 must succeed") + + assertTrue(v3Jar.exists(), "v3 JAR must exist") + assertTrue(v4Jar.exists(), "v4 JAR must exist") + assertTrue(v5Jar.exists(), "v5 JAR must exist") + assertTrue( + v2Jar.exists(), + "Candidate v2 JAR from failed reload must be retained across subsequent successful reloads", + ) + } + + @Test + fun `preflight rejects reload of unloaded system plugin`() = + runBlocking { + val pluginId = "ai.rever.boss.system.core" + val stagingRoot = DevPluginArtifacts.stagingRoot() + createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val manager = createManager() + assertNull( + manager.getPluginInfo(pluginId), + "System plugin must not be loaded prior to reload", + ) + + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload of unloaded protected system plugin must fail") + val message = result.exceptionOrNull()?.message.orEmpty() + assertTrue( + message.contains("protected system plugin"), + "Exception message must indicate protected system plugin: $message", + ) + } +} + +class IncompatibleBinaryPlugin : ai.rever.boss.plugin.api.Plugin { + override val pluginId = "com.example.binary.compat" + override val displayName = "Incompatible Binary Plugin" + + override fun register(context: PluginContext) = throw NoSuchMethodError("simulated binary incompatibility") +} + +class ControllableFailingPlugin : ai.rever.boss.plugin.api.Plugin { + override val pluginId = "com.example.failed.restore" + override val displayName = "Controllable Failing Plugin" + + override fun register(context: PluginContext) { + if (System.getProperty("boss.test.fail_rollback_binary") == "true") { + System.clearProperty("boss.test.fail_rollback_binary") + throw NoSuchMethodError("Simulated binary incompatible rollback") + } + if (System.getProperty("boss.test.fail_rollback_register") == "true") { + System.clearProperty("boss.test.fail_rollback_register") + error("Simulated rollback reinstall failure in register()") + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/PluginManifestTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/PluginManifestTest.kt new file mode 100644 index 0000000000..12577140ac --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/PluginManifestTest.kt @@ -0,0 +1,432 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.components.plugin.DefaultPlugin +import ai.rever.boss.utils.ReloadResult +import ai.rever.boss.utils.SingleInstanceManager +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class PluginManifestTest { + @TempDir + lateinit var tempDir: Path + + @BeforeTest + fun setUp() { + val runtimeDir = File(tempDir.toFile(), "run") + SingleInstanceManager.runtimeDirOverride = runtimeDir + SingleInstanceManager.pluginReloadHandlerOverride = null + } + + @AfterTest + fun tearDown() { + SingleInstanceManager.release() + SingleInstanceManager.pluginReloadHandlerOverride = null + SingleInstanceManager.runtimeDirOverride = null + } + + @Test + fun `reload fails when the running host has no development reload handler`() { + assertTrue(SingleInstanceManager.acquireLock()) + + val result = SingleInstanceManager.reloadDevPlugin("sample-plugin") + + assertIs(result) + assertTrue(result.reason.contains("not available")) + } + + @Test + fun `HostMeta CURRENT_API_VERSION decouples from desktop app version and matches 1 dot x`() { + val apiVersion = HostMeta.CURRENT_API_VERSION + assertTrue( + apiVersion.matches(Regex("""^1\.\d+\.\d+.*""")), + "CURRENT_API_VERSION must adhere to plugin API baseline (1.x.x), got: $apiVersion", + ) + assertNotEquals("9.5.10", apiVersion, "CURRENT_API_VERSION must not be the desktop app version (9.5.10)") + } + + @Test + fun `SemVerValidator isCompatible evaluates true for compatible API versions`() { + val currentApi = HostMeta.CURRENT_API_VERSION + assertTrue( + SemVerValidator.isCompatible("1.0.51", currentApi), + "1.0.51 should be compatible with host API version $currentApi", + ) + assertTrue( + SemVerValidator.isCompatible("1.0.88", currentApi), + "1.0.88 should be compatible with host API version $currentApi", + ) + } + + @Test + fun `SemVerValidator isCompatible evaluates false for incompatible major version`() { + val currentApi = HostMeta.CURRENT_API_VERSION + assertFalse( + SemVerValidator.isCompatible("2.0.0", currentApi), + "Major version 2.0.0 must be incompatible with host API version $currentApi", + ) + } + + @Test + fun `pruneStagingHistory retains at most 3 latest version directories`() { + val pluginDevDir = Files.createDirectory(tempDir.resolve("sample-plugin")) + val timestamps = listOf(1000L, 2000L, 3000L, 4000L, 5000L) + + for (ts in timestamps) { + val vDir = pluginDevDir.resolve("v$ts") + Files.createDirectory(vDir) + Files.writeString(vDir.resolve("sample-plugin.jar"), "dummy jar content") + } + + assertEquals(5, Files.list(pluginDevDir).use { it.count() }) + + DevPluginArtifacts.pruneStagingHistory(pluginDevDir.toFile(), maxVersionsToKeep = 3) + + val remaining = + Files.list(pluginDevDir).use { stream -> + stream.map { it.fileName.toString() }.toList().sorted() + } + assertEquals(3, remaining.size, "Must keep at most 3 latest versions") + assertEquals(listOf("v3000", "v4000", "v5000"), remaining, "Must keep 3 newest timestamps") + } + + @Test + fun `pruning leaves staging in progress intact so its writer can finish`() { + val pluginDevDir = Files.createDirectory(tempDir.resolve("ignore-empty-test")) + val v1 = Files.createDirectory(pluginDevDir.resolve("v1000")) + Files.writeString(v1.resolve("ignore-empty-test.jar"), "valid jar 1") + val v2 = Files.createDirectory(pluginDevDir.resolve("v2000")) + Files.writeString(v2.resolve("ignore-empty-test.jar"), "valid jar 2") + + Files.createDirectory(pluginDevDir.resolve("v3000")) + val v4 = Files.createDirectory(pluginDevDir.resolve("v4000")) + Files.writeString(v4.resolve("ignore-empty-test.jar.part"), "incomplete") + val v5 = Files.createDirectory(pluginDevDir.resolve("v5000")) + Files.writeString(v5.resolve("ignore-empty-test.jar"), "") + + DevPluginArtifacts.pruneStagingHistory(pluginDevDir.toFile(), maxVersionsToKeep = 2) + + val remaining = + Files.list(pluginDevDir).use { stream -> + stream.map { it.fileName.toString() }.toList().sorted() + } + assertEquals(listOf("v1000", "v2000", "v3000", "v4000", "v5000"), remaining) + Files.move(v4.resolve("ignore-empty-test.jar.part"), v4.resolve("ignore-empty-test.jar")) + assertEquals("incomplete", Files.readString(v4.resolve("ignore-empty-test.jar"))) + } + + @Test + fun `SingleInstanceManager captures host-side exception during reload and returns structured RELOAD_FAILED`() { + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + throw OutOfMemoryError("Metaspace out of memory during plugin classloading") + } + + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + val result = SingleInstanceManager.reloadDevPlugin("sample-failing-tool") + assertIs(result) + assertTrue( + result.reason.contains("Metaspace out of memory"), + "Error response must preserve host-side exception message without socket drop: ${result.reason}", + ) + assertFalse( + result.reason.contains("Host closed connection unexpectedly"), + "Socket connection must remain open and return structured RELOAD_FAILED", + ) + } + + @Test + fun `findActiveDevJar resolves newest timestamp directory`() { + val devRoot = Files.createDirectory(tempDir.resolve("dev-root")) + val pluginDir = Files.createDirectory(devRoot.resolve("my-tool")) + val timestamps = listOf(1000L, 2000L, 5000L, 3000L) + + for (ts in timestamps) { + val vDir = Files.createDirectory(pluginDir.resolve("v$ts")) + Files.writeString(vDir.resolve("my-tool.jar"), "content-$ts") + } + + val activeJar = DevPluginArtifacts.findActiveDevJar("my-tool", devRoot.toFile()) + assertTrue(activeJar != null, "Active dev jar should be found") + assertTrue(activeJar.toString().contains("v5000"), "Active dev jar must be newest timestamp (v5000)") + + val allActive = DevPluginArtifacts.findAllActiveDevJars(devRoot.toFile()) + assertEquals(1, allActive.size) + assertEquals(activeJar, allActive.first()) + } + + @Test + fun `SingleInstanceManager captures nested exception cause and returns root cause detail`() { + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + val root = ClassNotFoundException("com.example.missing.RequiredDependency") + throw IllegalStateException("Failed to instantiate plugin class", root) + } + + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + val result = SingleInstanceManager.reloadDevPlugin("sample-nested-fail") + assertIs(result) + assertTrue( + result.reason.contains("ClassNotFoundException") && + result.reason.contains("RequiredDependency"), + "Error response must unwrap to root cause: ${result.reason}", + ) + } + + @Test + fun `SingleInstanceManager safely handles cyclic exception causes without hanging`() { + val cyclicError = RuntimeException("Outer cyclic error") + val innerError = RuntimeException("Inner cyclic error", cyclicError) + try { + val causeField = Throwable::class.java.getDeclaredField("cause") + causeField.isAccessible = true + causeField.set(cyclicError, innerError) + } catch (_: Exception) { + } + + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + throw cyclicError + } + + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + val result = SingleInstanceManager.reloadDevPlugin("sample-cyclic-fail") + assertIs(result) + assertTrue( + result.reason.contains("RuntimeException") && result.reason.contains("cyclic"), + "Error response must unwrap cyclic cause safely: ${result.reason}", + ) + } + + @Test + fun `SingleInstanceManager transmits non-ASCII UTF-8 characters cleanly without corruption`() { + val unicodeMessage = "Échec du chargement du module: 日本語エラー / 🚀" + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + throw IllegalStateException(unicodeMessage) + } + + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + val result = SingleInstanceManager.reloadDevPlugin("sample-unicode-tool") + assertIs(result) + assertTrue( + result.reason.contains("日本語エラー") && result.reason.contains("🚀"), + "Error response must preserve UTF-8 Unicode characters cleanly: ${result.reason}", + ) + } + + @Test + fun `DefaultPlugin deduplicateJars prefers version-rotated dev JAR over standard installed JAR`() { + val stagingBase = File(tempDir.toFile(), "staging-test") + stagingBase.mkdirs() + DevPluginArtifacts.stagingRootOverride = stagingBase + + try { + val pluginsDir = File(tempDir.toFile(), "plugins-dir") + pluginsDir.mkdirs() + + // 1. Standard installed JAR + val standardJar = File(pluginsDir, "my-plugin.jar") + writeSyntheticJar(standardJar, "my-plugin") + + // 2. Version-rotated dev JAR in staging root: my-plugin/v1000/my-plugin.jar + val versionDir = File(stagingBase, "my-plugin/v1000") + versionDir.mkdirs() + val versionJar = File(versionDir, "my-plugin.jar") + writeSyntheticJar(versionJar, "my-plugin") + + val discoveredDevJars = DefaultPlugin.findActiveDevJars(stagingBase) + assertEquals(1, discoveredDevJars.size) + assertEquals(versionJar.absolutePath, discoveredDevJars.single().absolutePath) + + // deduplicateJars must drop the standard JAR and retain the dev JAR candidate + val deduplicated = DefaultPlugin.deduplicateJars(listOf(standardJar, versionJar)) + assertEquals(1, deduplicated.size) + assertEquals(versionJar.absolutePath, deduplicated.single().absolutePath) + } finally { + DevPluginArtifacts.stagingRootOverride = null + } + } + + @Test + fun `DefaultPlugin deduplicateJars respects isProtectedPredicate`() { + val stagingBase = File(tempDir.toFile(), "protected-staging-root") + stagingBase.mkdirs() + DevPluginArtifacts.stagingRootOverride = stagingBase + + try { + val pluginsDir = File(tempDir.toFile(), "protected-plugins-dir") + pluginsDir.mkdirs() + + val standardJar = File(pluginsDir, "protected-plugin.jar") + writeSyntheticJar(standardJar, "protected-plugin") + standardJar.setLastModified(2000L) + + val versionDir = File(stagingBase, "protected-plugin/v1000") + versionDir.mkdirs() + val versionJar = File(versionDir, "protected-plugin.jar") + writeSyntheticJar(versionJar, "protected-plugin") + versionJar.setLastModified(5000L) + + // When isProtectedPredicate is true, dev JAR is penalized so standardJar wins even if older + val deduplicated = + DefaultPlugin.deduplicateJars(listOf(standardJar, versionJar)) { pluginId -> + pluginId == "protected-plugin" + } + assertEquals(1, deduplicated.size) + assertEquals(standardJar.absolutePath, deduplicated.single().absolutePath) + } finally { + DevPluginArtifacts.stagingRootOverride = null + } + } + + @Test + fun `DefaultPlugin deduplicateJars drops lone dev JAR claiming protected system plugin`() { + val stagingBase = File(tempDir.toFile(), "lone-protected-staging-root") + stagingBase.mkdirs() + DevPluginArtifacts.stagingRootOverride = stagingBase + + try { + val versionDir = File(stagingBase, "protected-system-plugin/v1000") + versionDir.mkdirs() + val versionJar = File(versionDir, "protected-system-plugin.jar") + writeSyntheticJar(versionJar, "protected-system-plugin") + versionJar.setLastModified(5000L) + + // A lone dev JAR claiming a protected plugin ID must be dropped immediately before grouping + val deduplicated = + DefaultPlugin.deduplicateJars(listOf(versionJar)) { pluginId -> + pluginId == "protected-system-plugin" + } + assertTrue(deduplicated.isEmpty(), "Lone dev JAR claiming protected plugin ID must be dropped") + } finally { + DevPluginArtifacts.stagingRootOverride = null + } + } + + @Test + fun `extractPluginIdFromManifestText reads pluginId with deps preceding it`() { + val manifestWithDepsFirst = + """ + { + "manifestVersion": 1, + "dependencies": [ + { + "id": "ai.rever.boss.gateway", + "version": "1.0.0" + } + ], + "pluginId": "com.example.actual-plugin" + } + """.trimIndent() + + val extracted = DevPluginArtifacts.extractPluginIdFromManifestText(manifestWithDepsFirst) + assertEquals("com.example.actual-plugin", extracted) + + val jarFile = File(tempDir.toFile(), "deps-first.jar") + jarFile.parentFile?.mkdirs() + java.util.jar.JarOutputStream(java.io.FileOutputStream(jarFile)).use { out -> + val entry = java.util.jar.JarEntry("META-INF/boss-plugin/plugin.json") + out.putNextEntry(entry) + out.write(manifestWithDepsFirst.toByteArray(java.nio.charset.StandardCharsets.UTF_8)) + out.closeEntry() + } + + assertTrue(DevPluginArtifacts.isValidDevJar(jarFile, "com.example.actual-plugin")) + assertFalse(DevPluginArtifacts.isValidDevJar(jarFile, "ai.rever.boss.gateway")) + assertEquals("com.example.actual-plugin", DefaultPlugin.extractPluginId(jarFile)) + } + + @Test + fun `DevPluginArtifacts isDevPluginJar avoids false positives on paths containing dev username`() { + val stagingBase = File(tempDir.toFile(), "staging-root") + stagingBase.mkdirs() + DevPluginArtifacts.stagingRootOverride = stagingBase + + try { + // Path inside staging root with version folder: TRUE + val validDevJar = File(stagingBase, "my-plugin/v1700000000/my-plugin.jar") + validDevJar.parentFile.mkdirs() + validDevJar.writeText("fake-jar") + assertTrue(DevPluginArtifacts.isDevPluginJar(validDevJar)) + + // Normal installed plugin path on a machine where username has 'dev': FALSE + val installedDirWithDevUser = File(tempDir.toFile(), "Users/dev_user/AppData/Local/plugins/my-plugin.jar") + installedDirWithDevUser.parentFile.mkdirs() + installedDirWithDevUser.writeText("fake-jar") + assertFalse(DevPluginArtifacts.isDevPluginJar(installedDirWithDevUser)) + + // Sibling directory named 'dev' outside staging root: FALSE + val outsideDevDir = File(tempDir.toFile(), "some/dev/folder/my-plugin.jar") + outsideDevDir.parentFile.mkdirs() + outsideDevDir.writeText("fake-jar") + assertFalse(DevPluginArtifacts.isDevPluginJar(outsideDevDir)) + } finally { + DevPluginArtifacts.stagingRootOverride = null + } + } + + @Test + fun `SingleInstanceManager reloadDevPlugin returns HostOffline on stale descriptor with dead port`() { + // Find an unused loopback TCP port by binding and immediately closing + val deadPort = java.net.ServerSocket(0).use { it.localPort } + + // Create a fake instance descriptor file pointing to this dead port + val runDir = File(tempDir.toFile(), "run") + runDir.mkdirs() + SingleInstanceManager.runtimeDirOverride = runDir + + val descriptorFile = File(runDir, "boss-instance.txt") + val token = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + descriptorFile.writeText("transport=TCP\nendpoint=$deadPort\ntoken=$token\n") + + val result = SingleInstanceManager.reloadDevPlugin("my-plugin", timeoutMs = 1000) + assertIs(result, "Should report HostOffline when port connection is refused") + } + + @Test + fun `reloadDevPlugin reports TimedOut when the host is alive but the reload does not confirm in time`() { + // The handler blocks past the client budget. The host still answers the + // probe ping, which is what separates "running and busy" from "offline". + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + Thread.sleep(4_000) + true + } + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + val result = SingleInstanceManager.reloadDevPlugin("slow-tool", timeoutMs = 1_000) + assertIs(result, "A live host that does not confirm is busy, not offline") + } + + @Test + fun `reloadDevPlugin refuses a path-shaped plugin id before touching the channel`() { + assertTrue(SingleInstanceManager.acquireLock()) + + val result = SingleInstanceManager.reloadDevPlugin("..") + assertIs(result) + assertTrue(result.reason.contains("Invalid plugin id"), "Got: ${result.reason}") + } + + private fun writeSyntheticJar( + targetFile: File, + pluginId: String, + ) { + targetFile.parentFile?.mkdirs() + java.util.jar.JarOutputStream(java.io.FileOutputStream(targetFile)).use { out -> + val entry = java.util.jar.JarEntry("META-INF/boss-plugin/plugin.json") + out.putNextEntry(entry) + val manifestJson = """{"id":"$pluginId","version":"1.0.0","displayName":"Test Plugin"}""" + out.write(manifestJson.toByteArray(java.nio.charset.StandardCharsets.UTF_8)) + out.closeEntry() + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/DevReloadResponseTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/DevReloadResponseTest.kt new file mode 100644 index 0000000000..31f625cdc1 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/DevReloadResponseTest.kt @@ -0,0 +1,70 @@ +package ai.rever.boss.utils + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.net.InetAddress +import java.net.ServerSocket +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import kotlin.test.assertTrue + +class DevReloadResponseTest { + @TempDir + lateinit var tempDir: Path + + @BeforeEach + fun useTempRuntimeDir() { + SingleInstanceManager.runtimeDirOverride = tempDir.resolve("run").toFile() + } + + @AfterEach + fun releaseChannel() { + SingleInstanceManager.release() + SingleInstanceManager.runtimeDirOverride = null + } + + @Test + fun `reload response warns when restoring the previous build also failed`() { + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + throw IllegalStateException("new build failed").apply { + addSuppressed(IllegalStateException("old build could not be restored")) + } + } + assertTrue(SingleInstanceManager.acquireLock()) + val result = SingleInstanceManager.reloadDevPlugin("diagnostic-plugin") + kotlin.test.assertIs(result) + assertTrue(result.reason.contains("Rollback failed"), result.reason) + assertTrue(result.reason.contains("new build failed"), result.reason) + } + + @Test + fun `only an exact plugin reload acknowledgment confirms success`() { + for (response in listOf("OK", "RELOAD_OK wrong-plugin", "RELOAD_OKevil", "RELOAD_OK test-plugin")) { + ServerSocket(0, 1, InetAddress.getLoopbackAddress()).use { server -> + val runtime = tempDir.resolve("run") + Files.createDirectories(runtime) + val descriptor = + InstanceDescriptor(SingleInstanceTransport.TCP, server.localPort.toString(), "a".repeat(64)) + Files.writeString(runtime.resolve("single-instance"), descriptor.encode()) + val reply = + CompletableFuture.runAsync { + server.accept().use { socket -> + socket.getInputStream().bufferedReader().readLine() + socket.getOutputStream().write((response + "\n").toByteArray()) + } + } + val result = SingleInstanceManager.reloadDevPlugin("test-plugin", timeoutMs = 1000) + reply.get(5, TimeUnit.SECONDS) + if (response == "RELOAD_OK test-plugin") { + kotlin.test.assertIs(result) + } else { + kotlin.test.assertIs(result) + } + } + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/SingleInstanceChannelTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/SingleInstanceChannelTest.kt index d1e7eaed5a..6d8ea9850f 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/SingleInstanceChannelTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/SingleInstanceChannelTest.kt @@ -604,6 +604,21 @@ class SingleInstanceChannelTest { assertFalse(called) } + @Test + fun `plugin dev reload refuses a path-shaped plugin id on the wire`() { + // The id joins straight onto the dev staging root on the host, so a + // traversal token must be refused before it is parsed as a request. + assertTrue(SingleInstanceManager.acquireLock()) + val descriptor = assertNotNull(readPublishedDescriptor()) + val base = "$PROTOCOL_VERSION ${descriptor.token} $VERB_PLUGIN_DEV_RELOAD " + assertEquals(RESPONSE_REJECTED, exchange(descriptor, "$base..")) + assertEquals(RESPONSE_REJECTED, exchange(descriptor, "$base..\\..\\etc")) + assertEquals(RESPONSE_REJECTED, exchange(descriptor, "${base}plugins/other")) + assertEquals(RESPONSE_REJECTED, exchange(descriptor, "${base}C:\\evil")) + assertEquals(RESPONSE_REJECTED, exchange(descriptor, base)) + assertEquals(RESPONSE_REJECTED, exchange(descriptor, base.dropLast(1))) + } + @Test fun `status JSON escapes platform strings`() { val previous = System.getProperty("os.arch") @@ -709,6 +724,22 @@ class SingleInstanceChannelTest { assertTrue(assertNotNull(failure).message.orEmpty().contains("response size limit")) } + @Test + fun `reloadDevPlugin transmits diagnostic error over 256 bytes`() { + val longDetail = "DiagnosticContextInfo_".repeat(16) + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + throw IllegalStateException(longDetail) + } + assertTrue(SingleInstanceManager.acquireLock()) + val result = SingleInstanceManager.reloadDevPlugin("diagnostic-plugin") + kotlin.test.assertIs(result) + assertTrue( + result.reason.length >= 350, + "Error response must preserve 350+ char diagnostic message: ${result.reason.length}", + ) + assertTrue(result.reason.contains("DiagnosticContextInfo_")) + } + // ==================== Helpers ==================== private fun runtimeDirPath(): Path = File(tempDir.toFile(), "run").toPath() diff --git a/docs/CLI.md b/docs/CLI.md index feb4340821..a26cef1ce0 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -26,6 +26,7 @@ You can install or update the CLI symlinks inside BossConsole via **Toolbox → | `boss status` | Checks running BossConsole health and status | `boss status --json` | | `boss doctor` | Reports health problems with suggested next steps (exit `2` when degraded) | `boss doctor --json` | | `boss mcp ` | Discovers and invokes MCP tools | `boss mcp list` | +| `boss plugin ` | Developer CLI: scaffold, validate, and link plugins | `boss plugin init my-tool` | | `boss completion ` | Generates shell tab-completion scripts | `boss completion bash > ~/.boss-complete.sh` | --- @@ -52,7 +53,7 @@ boss status --json ```json { "running": true, - "version": "9.5.7", + "version": "9.5.10", "os": "Windows 11", "arch": "amd64", "activeProject": "BossConsole", @@ -195,6 +196,33 @@ boss completion fish > ~/.config/fish/completions/boss.fish --- +## Plugin Developer CLI (`boss plugin`) + +The `boss plugin` command suite accelerates developing third-party plugins with scaffolding, validation, and hot-linking. See [`docs/PLUGIN_LAUNCHPAD.md`](PLUGIN_LAUNCHPAD.md) for full specifications. + +### 1. `boss plugin init ` +Scaffolds a new plugin project across templates (`mcp-tool`, `ui-panel`, `background-service`, `full`): +```bash +boss plugin init my-tool --template mcp-tool +boss plugin init my-service --template background-service --dir ~/plugins/my-service --json +``` + +### 2. `boss plugin validate []` +Validates a plugin source directory or packaged `.jar` against manifest rules, permitted permissions, and bytecode entrypoints: +```bash +boss plugin validate +boss plugin validate build/libs/my-plugin-0.1.0.jar --json +``` + +### 3. `boss plugin link []` +Links the plugin into `$BOSS_HOME/plugins/dev/`. If BossConsole is running, triggers a live hot-reload over the loopback IPC socket: +```bash +boss plugin link +boss plugin link . --json +``` + +--- + ## Process Exit Codes & Stream Guarantees The CLI adheres to strict UNIX process exit codes and standard stream separation: diff --git a/docs/PLUGIN_LAUNCHPAD.md b/docs/PLUGIN_LAUNCHPAD.md new file mode 100644 index 0000000000..c5e28c1b9b --- /dev/null +++ b/docs/PLUGIN_LAUNCHPAD.md @@ -0,0 +1,201 @@ +# BossConsole Plugin Launchpad & Developer CLI (`boss plugin`) + +The **Plugin Launchpad** provides developer tooling and a high-performance CLI suite for scaffolding, validating, and hot-reloading third-party plugins in BossConsole. + +--- + +## Command Reference + +### 1. `boss plugin init ` +Scaffolds a new, buildable plugin project with Gradle Kotlin Multiplatform and `boss-plugin-api`. + +```bash +# Basic MCP tool plugin scaffold +boss plugin init my-tools + +# Explicit template selection +boss plugin init my-service --template background-service + +# Custom output directory and overwrite flag +boss plugin init my-panel --template ui-panel --dir ~/projects/my-panel --force + +# Machine-readable JSON output +boss plugin init my-full-plugin --template full --json +``` + +#### Options & Flags +| Option | Short | Description | Default | +|---|---|---|---| +| `--template` | `-t` | Plugin template (`mcp-tool`, `ui-panel`, `background-service`, `full`) | `mcp-tool` | +| `--dir` | `-d` | Output directory path | `./` | +| `--force` | `-f` | Overwrite target directory if non-empty | `false` | +| `--json` | | Emit structured JSON output | `false` | + +```json +{ + "status": "scaffolded", + "pluginId": "my-tools", + "template": "mcp-tool", + "targetDir": "C:/Users/dev/my-tools", + "files": [ + "plugin.json", + "build.gradle.kts", + "settings.gradle.kts", + "gradlew", + "gradlew.bat", + "gradle/wrapper/gradle-wrapper.properties", + "gradle/wrapper/gradle-wrapper.jar", + "src/main/kotlin/com/example/mytools/MyToolsPlugin.kt", + "src/test/kotlin/com/example/mytools/MyToolsPluginTest.kt", + ".gitignore", + "README.md" + ] +} +``` + +--- + +### 2. `boss plugin validate []` +Validates a plugin source directory or packaged `.jar`/`.zip` archive against BossConsole canonical schemas, permissions, and bytecode integrity. + +```bash +# Validate current directory +boss plugin validate + +# Validate specific directory +boss plugin validate path/to/my-plugin + +# Validate packaged JAR archive +boss plugin validate build/libs/my-plugin-0.1.0.jar + +# Machine-readable JSON validation +boss plugin validate . --json +``` + +#### Verification Diagnostics +- **Directory Mode**: + - `target-exists`: Verifies directory exists. + - `manifest-exists`: Confirms presence of `plugin.json`. + - `manifest-json-valid`: Parses manifest schema without fatal exceptions. + - `id-format`: Enforces reverse domain notation (`^[a-zA-Z][a-zA-Z0-9_-]*(?:\.[a-zA-Z0-9_-]+)+$`). + - `version-format`: Validates SemVer conformance (`MAJOR.MINOR.PATCH`). + - `minApiVersion`: Asserts requirement $\le$ `HostMeta.CURRENT_API_VERSION` (`1.0.88`). + - `entrypoint-class`: Validates fully qualified class name syntax. + - `permissions`: Validates declared permissions against host RBAC permission identifier format. + - `mcp-tools`: Validates tool names against `^mcp__[a-zA-Z0-9_-]+__[a-zA-Z0-9_-]+$`. + - `host-manifest-contract`: Validates against host `PluginManifestReader.parseManifest` and `validateManifest`. +- **Archive Mode (`.jar` / `.zip`)**: + - Validates archive readability. + - Validates embedded `plugin.json`. + - **Bytecode Verification**: Ensures `mainClass.replace('.', '/') + ".class"` exists within archive bytecode entries and implements `Plugin`. + +#### Human-Readable Output Example +``` +Validating plugin at C:\Users\dev\my-tools... +[✓] target-exists: Target path exists: C:\Users\dev\my-tools +[✓] manifest-exists: plugin.json found +[✓] manifest-json-valid: plugin.json parsed successfully +[✓] id-format: Plugin ID 'com.example.my-tools' follows reverse domain notation +[✓] version-format: Plugin version '0.1.0' is valid SemVer +[✓] min-api-version: apiVersion '1.0.89' is compatible (host: 1.0.89) +[✓] entrypoint-class: Entrypoint class 'com.example.mytools.MyToolsPlugin' is a valid fully-qualified class name +[✓] permissions: Declared permissions list is empty (accessible to all authenticated users) +[✓] mcp-tools: All 1 MCP tool declarations are valid +[✓] host-manifest-contract: Manifest conforms to host PluginManifestReader contract + +[✓] Validation passed (10/10 checks passed) +``` + +--- + +### 3. `boss plugin link []` +Links or stages a local compiled plugin JAR into the BossConsole version-rotated development directory (`~/.boss/plugins/dev//v/.jar`). + +```bash +# Link local plugin project (must be built first with ./gradlew build) +boss plugin link . + +# Link specific directory +boss plugin link path/to/my-plugin + +# Machine-readable JSON output +boss plugin link . --json +``` + +#### Hot-Reload Lifecycle +1. **Artifact Resolution**: Resolves the compiled JAR from `build/libs/-*.jar`. Fails fast with an actionable error if the project has not been compiled (`./gradlew build` required before linking). +2. **Pre-flight Validation**: Automatically runs `PluginValidator.validate` against the target JAR archive before modifying any staging files. Invalid plugins fail fast with exit code `1`. +3. **Version-Rotated Staging**: Stages the JAR into: + `~/.boss/plugins/dev//v/.jar` + (or `~/.boss_debug/plugins/dev/...` in dev mode). + This prevents Windows file-locking collisions on running classloaders. The host keeps the latest 3 builds plus every build that may have acquired a classloader during this session, including failed reload candidates. Those extra paths remain until restart; a later successful reload can prune them. The offline CLI never prunes staging. +4. **Instance Detection & Hot-Reload**: + - **If BossConsole is running**: Dispatches ` PLUGIN_DEV_RELOAD \n` over loopback socket and awaits synchronous host acknowledgment (`RELOAD_OK`). Any host-side exceptions are captured and returned as `RELOAD_FAILED ` without dropping the socket. + - **If BossConsole is offline**: Stages the plugin cleanly into the version-rotated dev folder and reports ready for next launch (`status: "staged", running: false`). + +The API, Toolbox, terminal, browser and editor system plugins cannot be overridden through dev staging. Live reload refuses protected identities and plugins owning native resources; their normal installation/update workflow and an application restart remain necessary. A plugin that exists only in dev staging has no installed record, so disabling it is not persisted across restart. Remove its staging directory while BOSS is stopped to remove that dev override. + +A failed newest dev build falls back to the installed store build on startup, not to an older dev version. Rebuild and link a corrected version, or remove the failed staging directory while BOSS is stopped. A link during host startup can report failure before any plugin manager is ready; the staged build remains on disk. In-progress staging directories are left to their writer rather than pruned by another reload. + +> **Note on Startup Precedence**: At application launch, BossConsole prioritizes staged development JARs in `~/.boss/plugins/dev//` over installed store versions for that same plugin ID via pre-load resolution (`resolvePersistedEntryPath` and `deduplicateJars`). Since `boss plugin link` pre-validates JARs before staging, dev artifacts are well-formed; if a dev build is missing or corrupt, startup automatically falls back to the installed store build. Protected system plugins and plugins requiring application restart (`HotReloadPolicy`) are never overridden by development JARs. + +--- + +## Project Templates + +| Template | Primary Use Case | Default Permissions | MCP Tools | +|---|---|---|---| +| `mcp-tool` | Exposing MCP tools to AI agents | `[]` (Open to authenticated users) | 1 sample action tool | +| `ui-panel` | Custom Compose Desktop UI panels & tabs | `[]` (Open to authenticated users) | None | +| `background-service` | Autonomous background workers / daemons | `[]` (Open to authenticated users) | None | +| `full` | Enterprise plugins combining UI, MCP, and CLI | `[]` (Open to authenticated users) | 1 action tool | + +--- + +## Canonical Schemas + +### `plugin.json` Schema +```json +{ + "pluginId": "com.example.sample-plugin", + "displayName": "Sample Plugin", + "version": "0.1.0", + "description": "BossConsole plugin for sample-plugin", + "author": "Boss Developer", + "apiVersion": "1.0.88", + "mainClass": "com.example.sampleplugin.SamplePluginPlugin", + "requiredPermissions": [], + "mcpTools": [ + { + "name": "mcp__com_example_sample_plugin__action", + "description": "Executes sample-plugin action tool", + "adminOnly": false + } + ] +} +``` + +### Permission Model & RBAC +`requiredPermissions` declares the host RBAC permissions (e.g. `plugins.create`, `secret.read`, `api_key.create`) required to access and run the plugin. +- An empty list (`[]`) means the plugin is accessible to all authenticated users. +- Scaffolded starter templates emit `[]` by default so any non-admin developer can build, test, and link without hitting RBAC permission gating (`pluginAccessAllowed`). +- When non-empty, permissions must follow the standard dot/dash/underscore RBAC identifier format (`^[a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*$`). + +--- + +## Typical Developer Workflow + +```bash +# 1. Initialize project +boss plugin init code-helper --template mcp-tool +cd code-helper + +# 2. Build plugin JAR (Required before linking) +./gradlew build + +# 3. Validate project structure and compiled JAR +boss plugin validate . + +# 4. Link & live-reload into running BossConsole +boss plugin link . +``` diff --git a/scripts/boss b/scripts/boss index 8bccfe4080..7ea5cc0508 100755 --- a/scripts/boss +++ b/scripts/boss @@ -108,6 +108,37 @@ detect_and_route() { exit 1 } +forward_to_boss() { + BOSS_BIN="${BOSS_BIN:-}" + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" + if [ -z "$BOSS_BIN" ]; then + if [[ "$OSTYPE" == "darwin"* ]]; then + if [ -x "/Applications/BOSS.app/Contents/MacOS/BOSS" ]; then + BOSS_BIN="/Applications/BOSS.app/Contents/MacOS/BOSS" + elif [ -x "$HOME/Applications/BOSS.app/Contents/MacOS/BOSS" ]; then + BOSS_BIN="$HOME/Applications/BOSS.app/Contents/MacOS/BOSS" + elif [ -x "$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/BOSS.app/Contents/MacOS/BOSS" ]; then + BOSS_BIN="$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/BOSS.app/Contents/MacOS/BOSS" + fi + elif [[ "$OSTYPE" == "linux"* ]]; then + if command -v boss-desktop &> /dev/null; then + BOSS_BIN="boss-desktop" + elif [ -x "/opt/boss/bin/BOSS" ]; then + BOSS_BIN="/opt/boss/bin/BOSS" + elif [ -x "$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/bin/BOSS" ]; then + BOSS_BIN="$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/bin/BOSS" + fi + fi + fi + + if [ -n "$BOSS_BIN" ] && command -v "$BOSS_BIN" >/dev/null 2>&1; then + exec "$BOSS_BIN" "$@" + else + echo "Error: BOSS application binary not found. Set BOSS_BIN to the packaged executable." >&2 + exit 1 + fi +} + # Main command handling case "$1" in url) @@ -176,45 +207,25 @@ case "$1" in ;; plugin) - if [ -z "$2" ]; then - echo "Error: Plugin ID required" - echo "Usage: boss plugin " - exit 1 - fi - encoded_id=$(urlencode "$2") - deep_link="boss://plugin?id=${encoded_id}" - open_url "$deep_link" + subcommand="${2:-}" + case "$subcommand" in + init|validate|link|--help|-h|help|"") + forward_to_boss "$@" + ;; + *) + if [ $# -le 2 ]; then + encoded_id=$(urlencode "$2") + deep_link="boss://plugin?id=${encoded_id}" + open_url "$deep_link" + else + forward_to_boss "$@" + fi + ;; + esac ;; status|doctor|mcp|completion) - BOSS_BIN="${BOSS_BIN:-}" - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" - if [ -z "$BOSS_BIN" ]; then - if [[ "$OSTYPE" == "darwin"* ]]; then - if [ -x "/Applications/BOSS.app/Contents/MacOS/BOSS" ]; then - BOSS_BIN="/Applications/BOSS.app/Contents/MacOS/BOSS" - elif [ -x "$HOME/Applications/BOSS.app/Contents/MacOS/BOSS" ]; then - BOSS_BIN="$HOME/Applications/BOSS.app/Contents/MacOS/BOSS" - elif [ -x "$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/BOSS.app/Contents/MacOS/BOSS" ]; then - BOSS_BIN="$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/BOSS.app/Contents/MacOS/BOSS" - fi - elif [[ "$OSTYPE" == "linux"* ]]; then - if command -v boss-desktop &> /dev/null; then - BOSS_BIN="boss-desktop" - elif [ -x "/opt/boss/bin/BOSS" ]; then - BOSS_BIN="/opt/boss/bin/BOSS" - elif [ -x "$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/bin/BOSS" ]; then - BOSS_BIN="$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/bin/BOSS" - fi - fi - fi - - if [ -n "$BOSS_BIN" ] && command -v "$BOSS_BIN" >/dev/null 2>&1; then - exec "$BOSS_BIN" "$@" - else - echo "Error: BOSS application binary not found. Set BOSS_BIN to the packaged executable." >&2 - exit 1 - fi + forward_to_boss "$@" ;; --version|-v|version) diff --git a/scripts/boss.bat b/scripts/boss.bat index 4779cffe7a..34ecf66792 100644 --- a/scripts/boss.bat +++ b/scripts/boss.bat @@ -106,11 +106,15 @@ start "" "boss://terminal?command=%ENCODED%" goto :eof :cmd_plugin -if "%~2"=="" ( - echo Error: Plugin ID required - echo Usage: boss plugin ^ - exit /b 1 -) +if "%~2"=="" goto :cmd_forward_exe +if /i "%~2"=="init" goto :cmd_forward_exe +if /i "%~2"=="validate" goto :cmd_forward_exe +if /i "%~2"=="link" goto :cmd_forward_exe +if /i "%~2"=="--help" goto :cmd_forward_exe +if /i "%~2"=="-h" goto :cmd_forward_exe +if /i "%~2"=="help" goto :cmd_forward_exe +if not "%~3"=="" goto :cmd_forward_exe + call :urlencode "%~2" ENCODED start "" "boss://plugin?id=%ENCODED%" goto :eof @@ -119,12 +123,23 @@ goto :eof REM Preserve literal exclamation marks in JSON arguments. setlocal DisableDelayedExpansion if defined BOSS_EXE if not exist "%BOSS_EXE%" goto :cmd_missing_exe -if not defined BOSS_EXE set "BOSS_EXE=%LOCALAPPDATA%\Programs\BOSS\BOSS.exe" -if not exist "%BOSS_EXE%" set "BOSS_EXE=%ProgramFiles%\BOSS\BOSS.exe" -if not exist "%BOSS_EXE%" set "BOSS_EXE=%~dp0..\composeApp\build\compose\binaries\main\app\BOSS\BOSS.exe" -if not exist "%BOSS_EXE%" goto :cmd_missing_exe +if not defined BOSS_EXE if exist "%LOCALAPPDATA%\Programs\BOSS\BOSS.exe" set "BOSS_EXE=%LOCALAPPDATA%\Programs\BOSS\BOSS.exe" +if not defined BOSS_EXE if exist "%ProgramFiles%\BOSS\BOSS.exe" set "BOSS_EXE=%ProgramFiles%\BOSS\BOSS.exe" +if not defined BOSS_EXE if exist "%~dp0..\composeApp\build\compose\binaries\main\app\BOSS\BOSS.exe" set "BOSS_EXE=%~dp0..\composeApp\build\compose\binaries\main\app\BOSS\BOSS.exe" +if not defined BOSS_EXE goto :cmd_missing_exe +if exist "%~dp0boss.ps1" goto :cmd_forward_ps "%BOSS_EXE%" %* exit /b %ERRORLEVEL% +goto :eof + +:cmd_forward_ps +REM The exit code rides out on its own line, parsed only after powershell +REM returns, so %ERRORLEVEL% still holds the launcher's result. Inside a +REM parenthesized block the whole block parses first, where %ERRORLEVEL% is +REM stale and !ERRORLEVEL! stays literal because delayed expansion is off. +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0boss.ps1" %* +exit /b %ERRORLEVEL% +goto :eof :cmd_missing_exe >&2 echo Error: BOSS application binary not found. Set BOSS_EXE to the packaged executable. diff --git a/scripts/boss.ps1 b/scripts/boss.ps1 index 01288737a2..3773556115 100644 --- a/scripts/boss.ps1 +++ b/scripts/boss.ps1 @@ -228,14 +228,37 @@ switch ($Command.ToLower()) { } "plugin" { - if ([string]::IsNullOrEmpty($Argument)) { - Write-Error "Error: Plugin ID required" - Write-Host "Usage: boss.ps1 plugin " + $subcommands = @("init", "validate", "link", "--help", "-h", "help") + if (-not [string]::IsNullOrEmpty($Argument) -and $Argument.ToLower() -notin $subcommands -and $args.Count -le 2) { + $encoded = [System.Uri]::EscapeDataString($Argument) + $deepLink = "boss://plugin?id=$encoded" + Open-BossDeepLink $deepLink + } else { + $bossExe = $env:BOSS_EXE + if ($bossExe -and -not (Test-Path $bossExe -PathType Leaf)) { + [Console]::Error.WriteLine("Error: BOSS_EXE does not name an executable file.") + exit 1 + } + if (-not $bossExe -or -not (Test-Path $bossExe)) { + $bossExe = "$env:LOCALAPPDATA\Programs\BOSS\BOSS.exe" + } + if (-not (Test-Path $bossExe)) { + $bossExe = "$env:ProgramFiles\BOSS\BOSS.exe" + } + if (-not (Test-Path $bossExe)) { + $bossExe = "$PSScriptRoot\..\composeApp\build\compose\binaries\main\app\BOSS\BOSS.exe" + } + if (Test-Path $bossExe) { + $forwardArgs = @($args) + if ($PSVersionTable.PSVersion -ge [Version]"7.3") { + $PSNativeCommandArgumentPassing = 'Standard' + } + & $bossExe @forwardArgs | Out-Host + exit $LASTEXITCODE + } + [Console]::Error.WriteLine("Error: BOSS application binary not found. Set BOSS_EXE to the packaged executable.") exit 1 } - $encoded = [System.Uri]::EscapeDataString($Argument) - $deepLink = "boss://plugin?id=$encoded" - Open-BossDeepLink $deepLink } { $_ -in "status", "doctor", "mcp", "completion" } { @@ -258,7 +281,7 @@ switch ($Command.ToLower()) { if ($PSVersionTable.PSVersion -ge [Version]"7.3") { $PSNativeCommandArgumentPassing = 'Standard' } - & $bossExe @forwardArgs + & $bossExe @forwardArgs | Out-Host exit $LASTEXITCODE } [Console]::Error.WriteLine("Error: BOSS application binary not found. Set BOSS_EXE to the packaged executable.") diff --git a/scripts/test/test-headless-cli.sh b/scripts/test/test-headless-cli.sh old mode 100644 new mode 100755 From b8b0900d15dbede26894d99f68bdd181dc7c2895 Mon Sep 17 00:00:00 2001 From: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:14:30 +0530 Subject: [PATCH 36/49] feat(import): Bitwarden JSON and KeePass XML password importers (#679) * feat(import): Bitwarden JSON and KeePass XML password importers The CSV path already reads Bitwarden/1Password/LastPass/Chrome/Safari exports via the header aliases. This adds the two common export formats it cannot read: - BitwardenJsonParser: the unencrypted Bitwarden JSON export (the default Export vault offers). Reads login items only, resolves the website from the first URI or the item name, and returns nothing for an encrypted export rather than ciphertext masquerading as credentials. - KeePassXmlParser: the KeePass 2.x plaintext XML export. Gets two format traps right: an entry's is not imported as extra credentials, and Recycle Bin entries are skipped. Parses untrusted XML with DTDs and external entities disabled (XXE hardening). Both are wired into ImportFileReader's content sniffing, which now routes KeePass XML and Bitwarden JSON ahead of the HTML/CSV paths on their unambiguous markers. Both use the low-level JSON/DOM APIs the host already has, so there are no new dependencies and no serializable models to drift. Neither logs a value. * fix(import): read Bitwarden passwords verbatim, wire json/xml picker, anchor KeePass sniff Addresses the review on #679: 1. BitwardenJsonParser read the password through the trimming string() helper, so " pa ss " lost its edge whitespace and an all-whitespace password was dropped as if absent. The password is now read verbatim; labels stay trimmed. 2. The import file picker only offered csv/html/htm, so a Bitwarden .json or KeePass .xml export could not be selected. It now offers json and xml too. 3. looksLikeKeePass searched the whole text, so a CSV whose field merely contained " --- .../components/dialogs/ImportDataDialog.kt | 3 +- .../dialogs/ImportDataDialogContent.kt | 2 +- .../services/importer/BitwardenJsonParser.kt | 103 +++++++++++ .../services/importer/ImportFileReader.kt | 54 +++++- .../boss/services/importer/ImportModels.kt | 4 + .../boss/services/importer/ImportService.kt | 12 +- .../services/importer/KeePassXmlParser.kt | 166 ++++++++++++++++++ .../importer/BitwardenJsonParserTest.kt | 84 +++++++++ .../importer/ImportFileReaderFormatTest.kt | 144 +++++++++++++++ .../services/importer/KeePassXmlParserTest.kt | 132 ++++++++++++++ 10 files changed, 690 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/BitwardenJsonParser.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/KeePassXmlParser.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/BitwardenJsonParserTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/ImportFileReaderFormatTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/KeePassXmlParserTest.kt diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialog.kt index cceeee435b..4f25fbd1c5 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialog.kt @@ -227,7 +227,8 @@ private fun rememberImportPicker( } } }, - fileExtensions = listOf("csv", "html", "htm"), + // csv/html for browsers and CSV managers; json for a Bitwarden export; xml for a KeePass export. + fileExtensions = listOf("csv", "html", "htm", "json", "xml"), title = "Choose a passwords or bookmarks export", ) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialogContent.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialogContent.kt index 214ee54e3d..a2f1e7d2f1 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialogContent.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialogContent.kt @@ -79,7 +79,7 @@ internal fun ChooseSourceContent( Spacer(Modifier.height(BossTheme.space.lg)) SectionLabel("FROM A FILE") Text( - "Passwords from CSV, bookmarks from HTML - Chrome ⋮ ▸ Passwords ▸ Export, " + + "Passwords from CSV, Bitwarden JSON or KeePass XML; bookmarks from HTML - Chrome ⋮ ▸ Passwords ▸ Export, " + "Safari File ▸ Export, Firefox Passwords ▸ Export Logins.", style = BossTheme.type.body, color = BossTheme.colors.textSecondary, diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/BitwardenJsonParser.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/BitwardenJsonParser.kt new file mode 100644 index 0000000000..ed8e0dd2b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/BitwardenJsonParser.kt @@ -0,0 +1,103 @@ +package ai.rever.boss.services.importer + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull + +/** + * Reads a Bitwarden unencrypted JSON export (`Tools > Export vault`, "json"). + * + * The CSV path already covers Bitwarden's CSV export via the header aliases; this + * covers the JSON export, which is the default Bitwarden offers and which the CSV + * path cannot read. The shape: + * + * ```json + * { "encrypted": false, "items": [ + * { "type": 1, "name": "Example", "notes": "…", + * "login": { "uris": [ {"uri": "https://example.com"} ], + * "username": "john", "password": "hunter2" } } + * ]} + * ``` + * + * Only login items (`type == 1`) carry a credential; secure notes, cards and + * identities are skipped. Uses the low-level JSON element API - the same the + * Chromium reader uses - so there are no `@Serializable` models to drift, and + * unknown fields are ignored by construction. + * + * Handles plaintext passwords, so the logging rule is absolute: this parses and + * returns, and never logs a value. + */ +object BitwardenJsonParser { + private val json = Json { ignoreUnknownKeys = true } + + /** Bitwarden's item type discriminator for a login. */ + private const val TYPE_LOGIN = 1 + + /** + * Every login in [text] as an [ImportedPassword], or an empty list when the + * text is not a Bitwarden export. + * + * An encrypted export (`"encrypted": true`) has no readable passwords, so it + * returns empty rather than a list of ciphertext masquerading as credentials. + */ + fun parse(text: String): List = parseEntries(text).filter { it.password.isNotEmpty() } + + /** Includes incomplete logins so the preview can explain why they are skipped. */ + internal fun parseEntries(text: String): List { + val root = runCatching { json.parseToJsonElement(text) as? JsonObject }.getOrNull() + // An encrypted export (`"encrypted": true`) has no readable passwords. + val items = + root + ?.takeIf { it["encrypted"] == null || (it["encrypted"] as? JsonPrimitive)?.contentOrNull == "false" } + ?.get("items") as? JsonArray + ?: return emptyList() + return items.mapNotNull { element -> (element as? JsonObject)?.let(::loginOf) } + } + + /** True when [text] parses as a JSON object with an `items` array - a cheap sniff for routing. */ + fun looksLikeBitwarden(text: String): Boolean { + val root = runCatching { json.parseToJsonElement(text) as? JsonObject }.getOrNull() ?: return false + return root["items"] is JsonArray + } + + /** One login item to a credential, including missing fields for preview validation. */ + private fun loginOf(item: JsonObject): ImportedPassword? { + val isLogin = (item["type"] as? JsonPrimitive)?.intOrNull == TYPE_LOGIN + val login = item["login"] as? JsonObject + // Read the password verbatim: edge whitespace is part of a password, not + // formatting, so it must not be trimmed away (and an all-spaces password + // must not be dropped as if it were absent). + val password = login?.rawString("password").orEmpty() + if (!isLogin || login == null) return null + + val website = firstUri(login) ?: item.string("name").orEmpty() + return ImportedPassword( + website = website, + username = login.string("username").orEmpty(), + password = password, + notes = item.string("notes"), + ) + } + + /** Prefer a web URI to native-app associations, keeping an app-only URI for the skipped preview. */ + private fun firstUri(login: JsonObject): String? { + val uris = login["uris"] as? JsonArray ?: return null + return uris + .asSequence() + .mapNotNull { (it as? JsonObject)?.string("uri") } + .toList() + .let { values -> values.firstOrNull { !isNonWebPasswordEntry(it) } ?: values.firstOrNull() } + } + + /** A string field, trimmed, or null when absent, JSON null, or blank. For labels (website/username/notes). */ + private fun JsonObject.string(key: String): String? = rawString(key)?.trim()?.ifEmpty { null } + + /** Only JSON strings are credentials; objects and numeric primitives are not passwords. */ + private fun JsonObject.rawString(key: String): String? { + val value = this[key] as? JsonPrimitive + return value?.takeIf { it.isString }?.contentOrNull + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportFileReader.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportFileReader.kt index d2a4c69624..443324a892 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportFileReader.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportFileReader.kt @@ -57,7 +57,15 @@ object ImportFileReader { path.endsWith(".html", ignoreCase = true) || path.endsWith(".htm", ignoreCase = true) - if (looksLikeHtml) parseBookmarkHtml(text) else parsePasswordCsv(text) + // Password-manager formats are sniffed before HTML/CSV: their markers + // are unambiguous, so a KeePass export saved with an odd extension is + // still read correctly. + when { + KeePassXmlParser.looksLikeKeePass(text) -> parseKeePassXml(text) + BitwardenJsonParser.looksLikeBitwarden(text) -> parseBitwardenJson(text) + looksLikeHtml -> parseBookmarkHtml(text) + else -> parsePasswordCsv(text) + } }.onFailure { error -> logger.warn( LogCategory.FILE, @@ -77,6 +85,48 @@ object ImportFileReader { return ImportPreview(bookmarks = bookmarks) } + private fun parseBitwardenJson(text: String): ImportPreview { + val passwords = BitwardenJsonParser.parseEntries(text) + if (passwords.isEmpty()) { + throw UnrecognisedImportFileException( + "No logins found in that Bitwarden export. Export the vault unencrypted and try again.", + ) + } + logger.info(LogCategory.AUTH, "Parsed Bitwarden export", mapOf("count" to passwords.size)) + return passwordManagerPreview(passwords) + } + + private fun parseKeePassXml(text: String): ImportPreview { + val passwords = KeePassXmlParser.parseEntries(text) + if (passwords.isEmpty()) { + throw UnrecognisedImportFileException( + "No entries with a password found in that KeePass export.", + ) + } + logger.info(LogCategory.AUTH, "Parsed KeePass export", mapOf("count" to passwords.size)) + return passwordManagerPreview(passwords) + } + + private fun passwordManagerPreview(entries: List): ImportPreview { + val passwords = mutableListOf() + val skipped = mutableListOf() + entries.forEachIndexed { index, entry -> + val reason = + when { + entry.website.isBlank() || isNonWebPasswordEntry(entry.website) -> SkipReason.MISSING_URL + entry.username.isBlank() -> SkipReason.MISSING_USERNAME + entry.password.isBlank() -> SkipReason.MISSING_PASSWORD + else -> null + } + if (reason == null) { + passwords.add(entry) + } else { + skipped.add(SkippedRow(index + 1, reason, displayLabel(entry.website, entry.username))) + } + } + return ImportPreview(passwords = passwords, skipped = skipped) + } + private fun parsePasswordCsv(text: String): ImportPreview { val table = CsvParser.parseTable(text) val columns = table?.let { resolvePasswordColumns(it.header) } @@ -173,7 +223,7 @@ object ImportFileReader { row.size <= maxOf(columns.url, columns.password) -> SkipReason.MALFORMED_ROW url.isEmpty() -> SkipReason.MISSING_URL username.isEmpty() -> SkipReason.MISSING_USERNAME - password.isEmpty() -> SkipReason.MISSING_PASSWORD + password.isBlank() -> SkipReason.MISSING_PASSWORD else -> null } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportModels.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportModels.kt index 3609832a40..5bffb3cdd4 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportModels.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportModels.kt @@ -99,3 +99,7 @@ internal fun displayLabel( website: String, username: String, ): String = if (username.isEmpty()) website else "$username @ $website" + +/** Native-app associations must never become browser website matches. */ +internal fun isNonWebPasswordEntry(raw: String): Boolean = + listOf("android://", "androidapp://", "iosapp://").any { raw.trim().startsWith(it, ignoreCase = true) } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportService.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportService.kt index 0500d6dd9d..5e6544fa07 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportService.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportService.kt @@ -68,7 +68,7 @@ object ImportService { val blocked = when { - website.isEmpty() || isNonWebEntry(entry.website) -> SkipReason.MISSING_URL + website.isEmpty() || isNonWebPasswordEntry(entry.website) -> SkipReason.MISSING_URL // Chrome stores logins with an empty username_value. // The CSV path pre-skips these; without the same check @@ -76,7 +76,7 @@ object ImportService { // them, and they render as red failures. entry.username.isBlank() -> SkipReason.MISSING_USERNAME - entry.password.isEmpty() -> SkipReason.MISSING_PASSWORD + entry.password.isBlank() -> SkipReason.MISSING_PASSWORD key in existing -> SkipReason.ALREADY_EXISTS @@ -178,14 +178,6 @@ object ImportService { return host?.lowercase()?.removePrefix("www.") ?: trimmed } - /** - * Entries a browser stores for native apps rather than web pages. - * - * Chrome exports rows like `android://@com.example`; they have no host - * and could never be autofilled, so importing them adds noise only. - */ - private fun isNonWebEntry(raw: String): Boolean = raw.trim().startsWith("android://", ignoreCase = true) - // ==================== Bookmarks ==================== /** diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/KeePassXmlParser.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/KeePassXmlParser.kt new file mode 100644 index 0000000000..59231b42bc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/KeePassXmlParser.kt @@ -0,0 +1,166 @@ +package ai.rever.boss.services.importer + +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.xml.sax.InputSource +import org.xml.sax.SAXParseException +import org.xml.sax.helpers.DefaultHandler +import java.io.StringReader +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Reads a KeePass 2.x plaintext XML export (`File > Export > KeePass XML (2.x)`). + * + * The `.kdbx` database itself is encrypted binary and needs the user's master + * key, so it is out of scope; the XML export writes the entries in the clear, + * which is what a migrating user produces. The shape: + * + * ```xml + * + * + * TitleExample + * UserNamejohn + * Passwordhunter2 + * URLhttps://example.com + * + * … nested groups … + * + * ``` + * + * Two format traps this gets right: + * + * - **History is not descended into.** An `` can hold a `` of its + * own past versions, each a full ``; a naive "every Entry" walk would + * import a credential once per revision. This reads only an entry's direct + * `` children and never recurses into an entry. + * - **The Recycle Bin is skipped.** Deleted entries live in a group whose UUID + * the `` block names; importing them would resurrect passwords the user + * threw away. + * + * Parses untrusted XML, so external entities and DTDs are disabled (XXE). Handles + * plaintext passwords: this parses and returns, and never logs a value. + */ +object KeePassXmlParser { + /** UUID KeePass writes when the Recycle Bin is unset (16 zero bytes, Base64). */ + private const val EMPTY_UUID = "AAAAAAAAAAAAAAAAAAAAAA==" + + /** + * Every credential in [text], or an empty list when it is not a KeePass XML + * export or cannot be parsed. + */ + fun parse(text: String): List = parseEntries(text).filter { it.password.isNotEmpty() } + + /** Includes incomplete entries for the preview, but never history or deleted entries. */ + internal fun parseEntries(text: String): List { + val doc = runCatching { secureDocument(text) }.getOrNull() + val root = + doc?.documentElement?.takeIf { it.tagName == "KeePassFile" } + ?: return emptyList() + + val out = mutableListOf() + childElement(root, "Root")?.let { collectFrom(it, recycleBinUuid(root), out) } + return out + } + + /** + * A cheap sniff for routing: the document's ROOT element is ``. + * + * Anchored at the start (past an optional BOM, XML declaration and comments) + * rather than searching the whole text - otherwise a CSV or bookmark file + * whose data merely contains the string `]*\?>\s*)?(?:\s*)*)""", + setOf(RegexOption.DOT_MATCHES_ALL), + ) + + /** Walk [node]'s child groups and entries, skipping the Recycle Bin group. */ + private fun collectFrom( + node: Element, + recycleBin: String?, + out: MutableList, + ) { + for (child in childElements(node)) { + when (child.tagName) { + "Group" -> { + val uuid = childElement(child, "UUID")?.textContent?.trim() + // Skip deleted entries; descend into every other group. + if (uuid == null || uuid != recycleBin) collectFrom(child, recycleBin, out) + } + + "Entry" -> { + entryToPassword(child)?.let(out::add) + } + } + } + } + + /** One entry's `` fields to a credential, or null when it has no password. */ + private fun entryToPassword(entry: Element): ImportedPassword? { + val fields = HashMap() + for (field in childElements(entry)) { + // Only direct String children: this deliberately does not enter . + if (field.tagName == "String") { + val key = childElement(field, "Key")?.textContent?.trim() + if (!key.isNullOrEmpty()) { + fields[key] = childElement(field, "Value")?.textContent ?: "" + } + } + } + + // Edge whitespace is part of a password, not formatting, so it is not trimmed. + val password = fields["Password"].orEmpty() + + val url = fields["URL"]?.trim().orEmpty() + val website = url.ifEmpty { fields["Title"]?.trim().orEmpty() } + return ImportedPassword( + website = website, + username = fields["UserName"]?.trim().orEmpty(), + password = password, + notes = fields["Notes"]?.trim()?.ifEmpty { null }, + ) + } + + /** The Recycle Bin group's UUID when set, including a disabled bin with old entries, else null. */ + private fun recycleBinUuid(root: Element): String? { + val meta = childElement(root, "Meta") ?: return null + val uuid = childElement(meta, "RecycleBinUUID")?.textContent?.trim() + return uuid?.takeIf { it.isNotEmpty() && it != EMPTY_UUID } + } + + /** Parse [text] with external entities and DTDs disabled (XXE hardening). */ + private fun secureDocument(text: String): Document { + val factory = DocumentBuilderFactory.newInstance() + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + factory.setFeature("http://xml.org/sax/features/external-general-entities", false) + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false) + factory.isXIncludeAware = false + factory.isExpandEntityReferences = false + val builder = factory.newDocumentBuilder() + // The default handler writes untrusted XML fragments to stderr on parse errors. + builder.setErrorHandler( + object : DefaultHandler() { + override fun error(error: SAXParseException): Unit = throw error + + override fun fatalError(error: SAXParseException): Unit = throw error + }, + ) + return builder.parse(InputSource(StringReader(text.removePrefix("\uFEFF")))) + } + + private fun childElements(node: Element): List { + val children = node.childNodes + return (0 until children.length).mapNotNull { children.item(it) as? Element } + } + + private fun childElement( + node: Element, + name: String, + ): Element? = childElements(node).firstOrNull { it.tagName == name } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/BitwardenJsonParserTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/BitwardenJsonParserTest.kt new file mode 100644 index 0000000000..763e6ef344 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/BitwardenJsonParserTest.kt @@ -0,0 +1,84 @@ +package ai.rever.boss.services.importer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Pins [BitwardenJsonParser] against the shape of an unencrypted Bitwarden JSON export. */ +class BitwardenJsonParserTest { + private val export = + """ + { + "encrypted": false, + "folders": [ { "id": "f1", "name": "Work" } ], + "items": [ + { "type": 1, "name": "Example", "notes": "a note", + "login": { "uris": [ { "uri": "https://example.com" } ], + "username": "john", "password": "hunter2" } }, + { "type": 1, "name": "NoUriEntry", + "login": { "username": "jane", "password": "pw2" } }, + { "type": 2, "name": "Secure note", "notes": "not a login" }, + { "type": 1, "name": "Passwordless", + "login": { "username": "nobody", "password": "" } } + ] + } + """.trimIndent() + + @Test + fun `parses login items and skips the rest`() { + val result = BitwardenJsonParser.parse(export) + assertEquals(2, result.size, "only the two logins with a password") + + val example = result.first { it.username == "john" } + assertEquals("https://example.com", example.website) + assertEquals("hunter2", example.password) + assertEquals("a note", example.notes) + } + + @Test + fun `falls back to the item name when no URI is present`() { + val noUri = BitwardenJsonParser.parse(export).first { it.username == "jane" } + assertEquals("NoUriEntry", noUri.website) + assertEquals("pw2", noUri.password) + assertNull(noUri.notes) + } + + @Test + fun `an encrypted export yields nothing rather than ciphertext`() { + val encrypted = export.replace("\"encrypted\": false", "\"encrypted\": true") + assertTrue(BitwardenJsonParser.parse(encrypted).isEmpty()) + } + + @Test + fun `a password is read verbatim, keeping edge whitespace`() { + val json = + """ + { "encrypted": false, "items": [ + { "type": 1, "name": "Spaced", + "login": { "username": "u", "password": " pa ss " } } + ]} + """.trimIndent() + assertEquals(" pa ss ", BitwardenJsonParser.parse(json).single().password) + } + + @Test + fun `an all-whitespace password is preserved, not dropped`() { + val json = + """ + { "encrypted": false, "items": [ + { "type": 1, "name": "Spaces", + "login": { "username": "u", "password": " " } } + ]} + """.trimIndent() + assertEquals(" ", BitwardenJsonParser.parse(json).single().password) + } + + @Test + fun `sniffing recognises a Bitwarden export and rejects other text`() { + assertTrue(BitwardenJsonParser.looksLikeBitwarden(export)) + assertFalse(BitwardenJsonParser.looksLikeBitwarden("url,username,password\na,b,c")) + assertFalse(BitwardenJsonParser.looksLikeBitwarden("{\"not\": \"bitwarden\"}")) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/ImportFileReaderFormatTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/ImportFileReaderFormatTest.kt new file mode 100644 index 0000000000..f224fd1c4b --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/ImportFileReaderFormatTest.kt @@ -0,0 +1,144 @@ +package ai.rever.boss.services.importer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins the format routing in [ImportFileReader.parseContent]: the new Bitwarden + * JSON and KeePass XML paths are reached, and the pre-existing CSV path is not + * disturbed. + */ +class ImportFileReaderFormatTest { + @Test + fun `routes a Bitwarden JSON export to the Bitwarden parser`() { + val json = + """ + { "encrypted": false, "items": [ + { "type": 1, "name": "Example", + "login": { "uris": [ { "uri": "https://example.com" } ], + "username": "john", "password": "hunter2" } } + ]} + """.trimIndent() + val preview = ImportFileReader.parseContent("vault.json", json).getOrThrow() + assertEquals(1, preview.passwords.size) + assertEquals("john", preview.passwords.first().username) + } + + @Test + fun `routes a KeePass XML export to the KeePass parser`() { + val xml = + """ + + + + TitleExample + UserNamejohn + Passwordhunter2 + URLhttps://example.com + + + """.trimIndent() + val preview = ImportFileReader.parseContent("db.xml", xml).getOrThrow() + assertEquals(1, preview.passwords.size) + assertEquals("https://example.com", preview.passwords.first().website) + } + + @Test + fun `still routes a plain CSV to the CSV parser`() { + val csv = "url,username,password\nhttps://example.com,john,hunter2\n" + val preview = ImportFileReader.parseContent("passwords.csv", csv).getOrThrow() + assertEquals(1, preview.passwords.size) + assertEquals("hunter2", preview.passwords.first().password) + } + + @Test + fun `a CSV whose field contains the KeePass marker still parses as CSV`() { + val csv = "url,username,password,notes\nhttps://x.com,john,hunter2,\"see note\"\n" + val preview = ImportFileReader.parseContent("passwords.csv", csv).getOrThrow() + assertEquals(1, preview.passwords.size) + assertEquals("john", preview.passwords.first().username) + } + + @Test + fun `Bitwarden preview reports incomplete and native-app rows before import`() { + val json = """{"items":[ + {"type":1,"name":"example.test","login":{"username":"u","password":" keep "}}, + {"type":1,"name":"example.test","login":{"password":"p"}}, + {"type":1,"name":"example.test","login":{"username":"u","password":""}}, + {"type":1,"login":{"username":"u","password":"p","uris":[{"uri":"androidapp://com.example"}]}}, + {"type":1,"login":{"username":"u","password":"p","uris":[{"uri":"iosapp://com.example"}]}} + ]}""" + val preview = ImportFileReader.parseContent("vault.json", json).getOrThrow() + assertEquals(" keep ", preview.passwords.single().password) + assertEquals( + listOf( + SkipReason.MISSING_USERNAME, + SkipReason.MISSING_PASSWORD, + SkipReason.MISSING_URL, + SkipReason.MISSING_URL, + ), + preview.skipped.map { it.reason }, + ) + } + + @Test + fun `whitespace-only passwords are skipped to match the vault request contract`() { + val json = """{"items":[{"type":1,"name":"example.test", + "login":{"username":"u","password":" "}}]}""" + val preview = ImportFileReader.parseContent("vault.json", json).getOrThrow() + assertTrue(preview.passwords.isEmpty()) + assertEquals(SkipReason.MISSING_PASSWORD, preview.skipped.single().reason) + val csv = "url,username,password\nexample.test,u, \n" + val csvPreview = ImportFileReader.parseContent("vault.csv", csv).getOrThrow() + assertEquals(SkipReason.MISSING_PASSWORD, csvPreview.skipped.single().reason) + } + + @Test + fun `a web URI after an app association is retained`() { + val json = """{"items":[{"type":1,"login":{"username":"u","password":"p", + "uris":[{"uri":"androidapp://com.example"},{"uri":"https://example.test"}]}}]}""" + val preview = ImportFileReader.parseContent("vault.json", json).getOrThrow() + assertEquals("https://example.test", preview.passwords.single().website) + } + + @Test + fun `malformed JSON fields cannot escape as credential-bearing exceptions`() { + val json = """{"items":[{"type":1,"name":"example.test", + "login":{"username":"u","password":{"synthetic-secret":"value"}}}]}""" + val preview = ImportFileReader.parseContent("vault.json", json).getOrThrow() + assertTrue(preview.passwords.isEmpty()) + assertEquals(SkipReason.MISSING_PASSWORD, preview.skipped.single().reason) + val invalidHeader = """{"encrypted":{"synthetic-secret":"value"},"items":[]}""" + val failure = ImportFileReader.parseContent("vault.json", invalidHeader).exceptionOrNull() + assertTrue(failure is UnrecognisedImportFileException) + assertTrue( + failure.message + .orEmpty() + .contains("synthetic-secret") + .not(), + ) + } + + @Test + fun `KeePass preview explains incomplete credentials`() { + val xml = """ + Titleexample.test + Passwordp + Titleexample.test + UserNameu + """ + val preview = ImportFileReader.parseContent("vault.xml", xml).getOrThrow() + assertTrue(preview.passwords.isEmpty()) + assertEquals( + listOf(SkipReason.MISSING_USERNAME, SkipReason.MISSING_PASSWORD), + preview.skipped.map { it.reason }, + ) + } + + @Test + fun `an unrecognised file fails with a clear error`() { + val result = ImportFileReader.parseContent("mystery.txt", "just some prose, nothing structured") + assertTrue(result.isFailure) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/KeePassXmlParserTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/KeePassXmlParserTest.kt new file mode 100644 index 0000000000..b744aa3140 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/KeePassXmlParserTest.kt @@ -0,0 +1,132 @@ +package ai.rever.boss.services.importer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pins [KeePassXmlParser] against a KeePass 2.x XML export, including the two + * format traps: an entry's `` must not be imported as extra credentials, + * and Recycle Bin entries must be skipped. Also checks XXE hardening. + */ +class KeePassXmlParserTest { + private val export = + """ + + + + True + UkVDWUNMRQ== + + + + Root + Um9vdA== + + TitleExample + UserNamejohn + Passwordhunter2 + URLhttps://example.com + Notesa note + + + Passwordoldpw + URLhttps://old.example.com + + + + + Subgroup + U3ViZ3JvdXA= + + TitleNested + Passwordnestedpw + + + + Recycle Bin + UkVDWUNMRQ== + + TitleDeleted + Passworddeletedpw + + + + + + """.trimIndent() + + @Test + fun `parses entries across nested groups`() { + val result = KeePassXmlParser.parse(export) + assertEquals(2, result.size, "Example and Nested only") + + val example = result.first { it.website == "https://example.com" } + assertEquals("john", example.username) + assertEquals("hunter2", example.password) + assertEquals("a note", example.notes) + + val nested = result.first { it.password == "nestedpw" } + assertEquals("Nested", nested.website, "URL absent, so the title is the website") + } + + @Test + fun `does not import an entry's history versions`() { + assertTrue(KeePassXmlParser.parse(export).none { it.password == "oldpw" }) + } + + @Test + fun `skips entries in the Recycle Bin`() { + assertTrue(KeePassXmlParser.parse(export).none { it.password == "deletedpw" }) + } + + @Test + fun `a disabled Recycle Bin still excludes its deleted entries`() { + val disabled = export.replace("True", "False") + assertEquals(2, KeePassXmlParser.parse(disabled).size) + assertTrue(KeePassXmlParser.parse(disabled).none { it.password == "deletedpw" }) + } + + @Test + fun `BOM is accepted and malformed XML never writes credential fragments to stderr`() { + assertEquals(2, KeePassXmlParser.parse("\uFEFF" + export).size) + val original = System.err + val captured = java.io.ByteArrayOutputStream() + try { + System.setErr(java.io.PrintStream(captured)) + assertTrue(KeePassXmlParser.parse("").isEmpty()) + assertEquals("", captured.toString()) + } finally { + System.setErr(original) + } + } + + @Test + fun `sniffing recognises a KeePass export and rejects other text`() { + assertTrue(KeePassXmlParser.looksLikeKeePass(export)) + assertTrue(KeePassXmlParser.looksLikeKeePass(""), "no xml declaration") + assertFalse(KeePassXmlParser.looksLikeKeePass("not keepass")) + } + + @Test + fun `a CSV whose data merely contains the marker is not misrouted to KeePass`() { + // The marker appears inside a field, not as the document root. + val csv = "url,username,password,notes\nhttps://x.com,u,p,\"\"\n" + assertFalse(KeePassXmlParser.looksLikeKeePass(csv), "root anchoring: a field value is not the root element") + } + + @Test + fun `a DOCTYPE with an external entity is refused, not expanded`() { + val xxe = + """ + + ]> + + Password&xxe; + + """.trimIndent() + // disallow-doctype-decl makes the parse fail; parse() returns empty rather than throwing. + assertTrue(KeePassXmlParser.parse(xxe).isEmpty()) + } +} From 1e1c9d3a98d8e3546c4ca7a15ac42bdc0cc573cd Mon Sep 17 00:00:00 2001 From: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:15:16 +0530 Subject: [PATCH 37/49] fix(filesystem): report failed file mutations (#680) * fix(filesystem): report failed file mutations * fix(filesystem): harden non-recursive deletion * test(filesystem): harden mutation regression coverage --------- Co-authored-by: ayush Co-authored-by: Shivang --- .../boss-service-filesystem/build.gradle.kts | 1 + .../filesystem/FileSystemServiceImpl.kt | 32 +- .../FileSystemServiceMutationTest.kt | 307 ++++++++++++++++++ 3 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceMutationTest.kt diff --git a/modules/boss-service-filesystem/build.gradle.kts b/modules/boss-service-filesystem/build.gradle.kts index 648d6885de..d7d33b0d29 100644 --- a/modules/boss-service-filesystem/build.gradle.kts +++ b/modules/boss-service-filesystem/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { runtimeOnly(libs.slf4j.simple) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.grpc.netty) } tasks.jar { diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt index 7dfb610480..1c0d172190 100644 --- a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt @@ -16,6 +16,7 @@ import java.io.File import java.io.IOException import java.nio.file.AccessDeniedException import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.DirectoryNotEmptyException import java.nio.file.FileAlreadyExistsException import java.nio.file.FileSystems import java.nio.file.Files @@ -160,16 +161,27 @@ class FileSystemServiceImpl : FileSystemServiceGrpcKt.FileSystemServiceCoroutine } } + // Directory creation and create I/O exception mapping retain their legacy behavior in this scoped fix. override suspend fun createFile(request: CreateFileRequest): Empty = withContext(Dispatchers.IO) { logger.info("createFile: path={}, isDirectory={}", request.path, request.isDirectory) validatePath(request.path) val file = File(request.path) if (request.createParents) file.parentFile?.mkdirs() - if (request.isDirectory) file.mkdirs() else file.createNewFile() + if (request.isDirectory) { + file.mkdirs() + } else if (!file.createNewFile()) { + throw status(Status.ALREADY_EXISTS, "File already exists: ${request.path}", null) + } Empty.getDefaultInstance() } + /** + * Nonrecursive deletion preserves missing-target success for RPC compatibility. NIO removes the + * directory entry without following links and supplies typed failures that survive gRPC as statuses. + * Recursive deletion retains its legacy unchecked behavior; changing that contract is separate work. + * Only AccessDeniedException maps to PERMISSION_DENIED; other provider I/O errors remain INTERNAL. + */ override suspend fun deleteFile(request: DeleteFileRequest): Empty = withContext(Dispatchers.IO) { logger.info("deleteFile: path={}, recursive={}", request.path, request.recursive) @@ -177,6 +189,24 @@ class FileSystemServiceImpl : FileSystemServiceGrpcKt.FileSystemServiceCoroutine val file = File(request.path) if (request.recursive && file.isDirectory) { file.deleteRecursively() + } else if (!request.recursive) { + try { + Files.deleteIfExists(file.toPath()) + } catch (e: DirectoryNotEmptyException) { + throw status( + Status.FAILED_PRECONDITION, + "Cannot delete non-empty directory without recursive=true: ${request.path}", + e, + ) + } catch (e: AccessDeniedException) { + throw status(Status.PERMISSION_DENIED, "Access denied: ${request.path}", e) + } catch (e: IOException) { + throw status( + Status.INTERNAL, + "Failed to delete path: ${request.path}: ${e.message ?: e::class.java.simpleName}", + e, + ) + } } else { file.delete() } diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceMutationTest.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceMutationTest.kt new file mode 100644 index 0000000000..2b4185b953 --- /dev/null +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceMutationTest.kt @@ -0,0 +1,307 @@ +package ai.rever.boss.service.filesystem + +import ai.rever.boss.ipc.proto.services.CreateFileRequest +import ai.rever.boss.ipc.proto.services.DeleteFileRequest +import ai.rever.boss.ipc.proto.services.FileSystemServiceGrpcKt +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.Status +import io.grpc.StatusException +import io.grpc.netty.NettyServerBuilder +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import java.io.File +import java.io.IOException +import java.net.InetSocketAddress +import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS +import java.nio.file.attribute.PosixFilePermission +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FileSystemServiceMutationTest { + private val service = FileSystemServiceImpl() + private val testDirectory = + File.createTempFile("filesystem-service-mutation-", "").let { file -> + file.delete() + file.mkdirs() + file + } + + @AfterTest + fun cleanUp() { + testDirectory.deleteRecursively() + } + + @Test + fun `create regular file succeeds`() { + val file = testDirectory.resolve("new-file.txt") + + createFile(file) + + assertTrue(file.isFile) + } + + @Test + fun `create existing regular file is refused`() { + val file = testDirectory.resolve("existing-file.txt").apply { createNewFile() } + + val error = assertFailsWith { createFile(file) } + + assertEquals(Status.Code.ALREADY_EXISTS, error.status.code) + assertEquals("File already exists: ${file.absolutePath}", error.status.description) + assertTrue(file.isFile) + } + + @Test + fun `create file at directory path is refused`() { + val directory = testDirectory.resolve("occupied-by-directory").apply { mkdir() } + + val error = assertFailsWith { createFile(directory) } + + assertEquals(Status.Code.ALREADY_EXISTS, error.status.code) + assertEquals("File already exists: ${directory.absolutePath}", error.status.description) + assertTrue(directory.isDirectory) + } + + @Test + fun `gRPC client receives non-recursive delete refusal status and description`() { + val directory = testDirectory.resolve("non-empty-over-grpc").apply { mkdir() } + directory.resolve("child.txt").createNewFile() + + withGrpcService { stub -> + val error = + assertFailsWith { + runBlocking { + stub.deleteFile( + DeleteFileRequest + .newBuilder() + .setPath(directory.absolutePath) + .setRecursive(false) + .build(), + ) + } + } + + assertEquals(Status.Code.FAILED_PRECONDITION, error.status.code) + assertEquals( + "Cannot delete non-empty directory without recursive=true: ${directory.absolutePath}", + error.status.description, + ) + assertTrue(directory.isDirectory) + assertTrue(directory.resolve("child.txt").isFile) + } + } + + @Test + fun `create with a missing parent preserves the I O exception`() { + val file = testDirectory.resolve("missing-parent/file.txt") + + assertFailsWith { createFile(file) } + + assertFalse(file.exists()) + } + + @Test + fun `create with a regular-file parent preserves the I O exception`() { + val parent = testDirectory.resolve("regular-file-parent").apply { createNewFile() } + val file = parent.resolve("child.txt") + + assertFailsWith { createFile(file) } + + assertFalse(file.exists()) + } + + @Test + fun `delete regular file succeeds`() { + val file = testDirectory.resolve("delete-me.txt").apply { createNewFile() } + + deleteFile(file, recursive = false) + + assertFalse(file.exists()) + } + + @Test + fun `delete empty directory succeeds`() { + val directory = testDirectory.resolve("empty-directory").apply { mkdir() } + + deleteFile(directory, recursive = false) + + assertFalse(directory.exists()) + } + + @Test + fun `delete non-empty directory without recursion is refused`() { + val directory = testDirectory.resolve("non-empty-directory").apply { mkdir() } + val child = directory.resolve("child.txt").apply { createNewFile() } + + val error = assertFailsWith { deleteFile(directory, recursive = false) } + + assertEquals(Status.Code.FAILED_PRECONDITION, error.status.code) + assertEquals( + "Cannot delete non-empty directory without recursive=true: ${directory.absolutePath}", + error.status.description, + ) + assertTrue(directory.isDirectory) + assertTrue(child.isFile) + } + + @Test + fun `non-recursive delete reports permission denial for a protected dangling symlink`() { + val parent = testDirectory.resolve("protected-parent").apply { mkdir() } + val link = parent.resolve("dangling-link") + val permissions = createProtectedDanglingSymlink(parent, link) + + try { + // Establish the environment before invoking the service; never turn a failed assertion into a skip. + assumeTrue("The parent remains writable despite the permission fixture", !Files.isWritable(parent.toPath())) + val error = assertFailsWith { deleteFile(link, recursive = false) } + + assertEquals(Status.Code.PERMISSION_DENIED, error.status.code) + assertEquals("Access denied: ${link.absolutePath}", error.status.description) + assertTrue(Files.exists(link.toPath(), NOFOLLOW_LINKS)) + } finally { + Files.setPosixFilePermissions(parent.toPath(), permissions) + } + } + + @Test + fun `non-recursive delete removes a symlink without deleting its target`() { + val target = testDirectory.resolve("retained-target.txt").apply { writeText("keep me") } + val link = testDirectory.resolve("removable-link") + try { + Files.createSymbolicLink(link.toPath(), target.toPath()) + } catch (_: UnsupportedOperationException) { + assumeTrue("Symbolic links are unavailable on this platform", false) + } catch (_: IOException) { + assumeTrue("The environment cannot create the symbolic-link fixture", false) + } + + deleteFile(link, recursive = false) + + assertFalse(Files.exists(link.toPath(), NOFOLLOW_LINKS)) + assertEquals("keep me", target.readText()) + } + + @Test + fun `gRPC create with a missing parent retains the deferred UNKNOWN status`() { + val file = testDirectory.resolve("missing-grpc-parent/file.txt") + withGrpcService { stub -> + // Create IOException mapping is deferred; this pins the actual wire behavior, not the direct exception. + val error = + assertFailsWith { + runBlocking { + stub.createFile(CreateFileRequest.newBuilder().setPath(file.absolutePath).build()) + } + } + assertEquals(Status.Code.UNKNOWN, error.status.code) + assertEquals(null, error.status.description) + assertFalse(file.exists()) + } + } + + @Test + fun `delete missing target remains successful`() { + val missingFile = testDirectory.resolve("missing.txt") + + deleteFile(missingFile, recursive = false) + + assertFalse(missingFile.exists()) + } + + @Test + fun `recursive delete of a missing target remains successful`() { + val missingFile = testDirectory.resolve("missing-recursively.txt") + + deleteFile(missingFile, recursive = true) + + assertFalse(missingFile.exists()) + } + + @Test + fun `recursive delete succeeds`() { + val directory = testDirectory.resolve("recursive-directory").apply { mkdir() } + val nestedDirectory = directory.resolve("nested").apply { mkdir() } + nestedDirectory.resolve("child.txt").createNewFile() + + deleteFile(directory, recursive = true) + + assertFalse(directory.exists()) + } + + private fun createFile(file: File) { + runBlocking { + service.createFile( + CreateFileRequest + .newBuilder() + .setPath(file.absolutePath) + .setIsDirectory(false) + .build(), + ) + } + } + + private fun deleteFile( + file: File, + recursive: Boolean, + ) { + runBlocking { + service.deleteFile( + DeleteFileRequest + .newBuilder() + .setPath(file.absolutePath) + .setRecursive(recursive) + .build(), + ) + } + } + + private fun createProtectedDanglingSymlink( + parent: File, + link: File, + ): Set { + val permissions = + try { + Files.getPosixFilePermissions(parent.toPath()) + } catch (_: UnsupportedOperationException) { + assumeTrue("POSIX permissions are unavailable on this platform", false) + return emptySet() + } + + try { + Files.createSymbolicLink(link.toPath(), parent.resolve("missing-target").toPath()) + Files.setPosixFilePermissions(parent.toPath(), permissions - PosixFilePermission.OWNER_WRITE) + } catch (_: UnsupportedOperationException) { + assumeTrue("Symbolic links are unavailable on this platform", false) + } catch (_: IOException) { + assumeTrue("The environment cannot create the dangling-symlink fixture", false) + } + return permissions + } + + private fun withGrpcService(block: (FileSystemServiceGrpcKt.FileSystemServiceCoroutineStub) -> Unit) { + val server: Server = + NettyServerBuilder + .forAddress(InetSocketAddress("127.0.0.1", 0)) + .addService(service) + .build() + .start() + val channel: ManagedChannel = + ManagedChannelBuilder + .forAddress("127.0.0.1", server.port) + .usePlaintext() + .build() + try { + block(FileSystemServiceGrpcKt.FileSystemServiceCoroutineStub(channel)) + } finally { + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS) + server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS) + } + } +} From a44808359852be6d5d41b5e75d3d8ada0320cecd Mon Sep 17 00:00:00 2001 From: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:16:45 +0530 Subject: [PATCH 38/49] fix(updater): stop the update prompt stacking over another modal (#696) (#697) * fix(updater): stop the update prompt stacking over another modal (#696) On the heavyweight-overlay path (Windows) every modal is a separate always-on-top window. The app-update prompt is the one dialog that appears on its own timer rather than in response to the user, so it could pop up over a dialog the user was filling in, or another dialog could open over it. Two always-on-top heavyweight modal windows stacked is the state #696 reports as leaving BOSS unusable. HeavyweightModalRegistry counts the open heavyweight modals (UI-thread DisposableEffect in HeavyweightModal, mirroring the existing popup counter). The update prompt now yields to any other heavyweight modal in either direction - it does not open over one, and it steps aside when one opens over it - reappearing once that modal closes. The banner stays up meanwhile. The count is always 0 on the lightweight path, where Compose stacks dialogs correctly on its own, so that path is unchanged. shouldShowUpdateDialog is a pure gate (subtracting the prompt's own window out of the count so it does not hide itself), pinned by UpdateDialogGateTest across the open / stay / step-aside / return sequence. * fix(updater): pin modal registration ordering and test lifecycle --------- Co-authored-by: Shivang --- .../ai/rever/boss/app/BossAppScaffold.kt | 9 +- .../overlays/HeavyweightModalRegistry.kt | 39 +++++ .../ai/rever/boss/updater/UpdateDialogGate.kt | 58 +++++++ .../components/overlays/HeavyweightModal.kt | 2 + .../boss/updater/UpdateDialogGateTest.kt | 126 ++++++++++++++ .../boss/updater/UpdateDialogLifecycleTest.kt | 154 ++++++++++++++++++ 6 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModalRegistry.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/updater/UpdateDialogGate.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogGateTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogLifecycleTest.kt diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt index 838431bf81..e1dda281e7 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt @@ -60,6 +60,7 @@ import ai.rever.boss.plugin.ui.BossTheme import ai.rever.boss.services.bookmarks.BookmarkAPIAccess import ai.rever.boss.updater.UpdateAvailableDialog import ai.rever.boss.updater.UpdateBanner +import ai.rever.boss.updater.UpdateDialogGate import ai.rever.boss.updater.UpdateState import ai.rever.boss.updater.drawsBanner import ai.rever.boss.updater.rememberUpdateDialogOwnership @@ -571,7 +572,13 @@ internal fun BossAppScaffold( val showUpdateDialog by updateHandle.showUpdateDialog.collectAsState() val isUpdateDialogOwner = rememberUpdateDialogOwnership(state.windowId) val updateStateForDialog = updateState - if (showUpdateDialog && isUpdateDialogOwner && updateStateForDialog is UpdateState.UpdateAvailable) { + UpdateDialogGate( + wantDialog = showUpdateDialog, + isOwner = isUpdateDialogOwner, + updateAvailable = updateStateForDialog is UpdateState.UpdateAvailable, + ) { + // Re-test for the smart cast inside the gated content. + if (updateStateForDialog !is UpdateState.UpdateAvailable) return@UpdateDialogGate UpdateAvailableDialog( updateInfo = updateStateForDialog.updateInfo, onUpdateNow = { diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModalRegistry.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModalRegistry.kt new file mode 100644 index 0000000000..b3ae701d38 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModalRegistry.kt @@ -0,0 +1,39 @@ +package ai.rever.boss.components.overlays + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** + * Process-wide count of composed heavyweight modals, including plugin dialogs and the update prompt. + * UI-thread only: [TrackHeavyweightModal] pairs each acquisition with composition disposal. + * + * Only the update prompt yields to this count. Other autonomous prompts (including timed MCP + * approvals) remain ungated. Native and Settings lightweight dialogs are not represented here. + * The process-wide OFF_SCREEN path never acquires a registration. + */ +object HeavyweightModalRegistry { + var openCount: Int by mutableStateOf(0) + private set + + /** Called by [HeavyweightModal] when it enters composition. UI thread only. */ + fun acquire() { + openCount += 1 + } + + /** Called by [HeavyweightModal] when it leaves composition. UI thread only. */ + fun release() { + openCount = (openCount - 1).coerceAtLeast(0) + } +} + +/** Register in the caller's composition, before entering Window's separate content composition. */ +@Composable +internal fun TrackHeavyweightModal() { + DisposableEffect(Unit) { + HeavyweightModalRegistry.acquire() + onDispose { HeavyweightModalRegistry.release() } + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/updater/UpdateDialogGate.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/updater/UpdateDialogGate.kt new file mode 100644 index 0000000000..b5116467ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/updater/UpdateDialogGate.kt @@ -0,0 +1,58 @@ +package ai.rever.boss.updater + +import ai.rever.boss.components.overlays.HeavyweightModalRegistry +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * Whether the app-update prompt should be on screen right now. + * + * The update prompt can appear on its own timer (a background update check + * finishing) rather than in response to the user. On the heavyweight-overlay path (Windows), every + * modal is a separate always-on-top window, and two of them stacked is the state BossConsole#696 + * reports as leaving BOSS unusable. So the prompt yields to any OTHER heavyweight modal: it does not + * open over one, and it steps aside when one opens over it. The update banner still shows meanwhile, + * and the prompt reappears once the other modal closes. + * + * [openHeavyweightModals] is the total from [ai.rever.boss.components.overlays.HeavyweightModalRegistry], + * which counts the update prompt itself while it is up - so its own window is subtracted out via + * [updateDialogShowing], leaving the count of modals that are NOT this prompt. On the lightweight + * process-wide OFF_SCREEN path the count is always 0. The count spans all BOSS windows, so another + * window's heavyweight modal also defers this prompt. Native and Settings lightweight dialogs are + * not counted; this gate does not serialize other autonomous prompts or prove #696 fully resolved. + */ +internal fun shouldShowUpdateDialog( + wantDialog: Boolean, + isOwner: Boolean, + updateAvailable: Boolean, + openHeavyweightModals: Int, + updateDialogShowing: Boolean, +): Boolean { + if (!wantDialog || !isOwner || !updateAvailable) return false + val otherModals = openHeavyweightModals - if (updateDialogShowing) 1 else 0 + return otherModals <= 0 +} + +/** Keeps count invalidations local and commits self-accounting before the modal's registration. */ +@Composable +internal fun UpdateDialogGate( + wantDialog: Boolean, + isOwner: Boolean, + updateAvailable: Boolean, + content: @Composable () -> Unit, +) { + var showing by remember { mutableStateOf(false) } + if (shouldShowUpdateDialog(wantDialog, isOwner, updateAvailable, HeavyweightModalRegistry.openCount, showing)) { + // TrackHeavyweightModal commits in this same parent composition, after this effect. + // Keeping its registration outside Window's separate content composition pins that order. + DisposableEffect(Unit) { + showing = true + onDispose { showing = false } + } + content() + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModal.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModal.kt index 75d2eb4642..2288f87671 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModal.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModal.kt @@ -56,6 +56,8 @@ fun HeavyweightModal( // dismisses on Escape and on its own scrim - only the focus-loss path is suppressed. val dismissOnFocusLoss = LocalDismissModalOnFocusLoss.current + TrackHeavyweightModal() + Window( onCloseRequest = onDismissRequest, state = state, diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogGateTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogGateTest.kt new file mode 100644 index 0000000000..301d45fc10 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogGateTest.kt @@ -0,0 +1,126 @@ +package ai.rever.boss.updater + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pins [shouldShowUpdateDialog], the gate that keeps the autonomous app-update prompt from stacking + * a second always-on-top window over another heavyweight modal (BossConsole#696). + * + * The convergence argument the composable relies on is checked here as a sequence: the prompt opens + * only when nothing else is up, keeps itself up once it is the only modal, steps aside when another + * modal opens over it, and comes back when that modal closes - with no oscillation, because it + * subtracts its own window out of the count. + */ +class UpdateDialogGateTest { + @Test + fun `the base conditions still gate it`() { + assertFalse( + shouldShowUpdateDialog( + wantDialog = false, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 0, + updateDialogShowing = false, + ), + "no request means no dialog", + ) + assertFalse( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = false, + updateAvailable = true, + openHeavyweightModals = 0, + updateDialogShowing = false, + ), + "a non-owning window never hosts it", + ) + assertFalse( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = false, + openHeavyweightModals = 0, + updateDialogShowing = false, + ), + "no available update means no dialog", + ) + } + + @Test + fun `it opens when nothing else is on screen`() { + assertTrue( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 0, + updateDialogShowing = false, + ), + ) + } + + @Test + fun `it does not open over a modal already up`() { + assertFalse( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 1, + updateDialogShowing = false, + ), + ) + } + + @Test + fun `once it is the only modal it stays - its own window is not counted against it`() { + assertTrue( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 1, + updateDialogShowing = true, + ), + ) + } + + @Test + fun `it steps aside when a second modal opens over it`() { + assertFalse( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 2, + updateDialogShowing = true, + ), + ) + } + + @Test + fun `it stays hidden while the other modal is up, then returns when it closes`() { + // The other modal is still up and the prompt has already stepped aside (showing = false). + assertFalse( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 1, + updateDialogShowing = false, + ), + ) + // The other modal closes. + assertTrue( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 0, + updateDialogShowing = false, + ), + ) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogLifecycleTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogLifecycleTest.kt new file mode 100644 index 0000000000..bc3077d97b --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogLifecycleTest.kt @@ -0,0 +1,154 @@ +package ai.rever.boss.updater + +import ai.rever.boss.components.overlays.HeavyweightModalRegistry +import ai.rever.boss.components.overlays.TrackHeavyweightModal +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composition +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals + +class UpdateDialogLifecycleTest { + @Test + fun `real effects yield return and dispose without self oscillation or leaking registrations`() = + runBlocking { + assertEquals(0, HeavyweightModalRegistry.openCount) + var promptMounts = 0 + var promptDisposals = 0 + withCompositions { owner, otherWindow, settle -> + val otherOpen = mutableStateOf(false) + val ownsPrompt = mutableStateOf(true) + val requested = mutableStateOf(true) + + owner.setContent { + UpdateDialogGate(requested.value, ownsPrompt.value, updateAvailable = true) { + TrackHeavyweightModal() + DisposableEffect(Unit) { + promptMounts++ + onDispose { promptDisposals++ } + } + } + } + otherWindow.setContent { + if (otherOpen.value) TrackHeavyweightModal() + } + settle() + assertEquals(1, HeavyweightModalRegistry.openCount) + assertEquals(1, promptMounts) + assertEquals(0, promptDisposals) + + otherOpen.value = true + settle() + assertEquals(1, HeavyweightModalRegistry.openCount) + assertEquals(1, promptMounts) + assertEquals(1, promptDisposals) + assertEquals(true, requested.value, "yielding must not dismiss the requested update") + + otherOpen.value = false + settle() + assertEquals(1, HeavyweightModalRegistry.openCount) + assertEquals(2, promptMounts) + assertEquals(1, promptDisposals) + + ownsPrompt.value = false + settle() + assertEquals(0, HeavyweightModalRegistry.openCount) + assertEquals(2, promptDisposals) + + // Other-modal-first: ownership returns while another window still has a modal. + otherOpen.value = true + settle() + ownsPrompt.value = true + settle() + assertEquals(2, promptMounts) + assertEquals(1, HeavyweightModalRegistry.openCount) + otherOpen.value = false + settle() + assertEquals(3, promptMounts) + + requested.value = false + settle() + assertEquals(0, HeavyweightModalRegistry.openCount) + assertEquals(3, promptDisposals) + requested.value = true + settle() + assertEquals(4, promptMounts) + } + assertEquals(0, HeavyweightModalRegistry.openCount) + assertEquals(promptMounts, promptDisposals) + } + + private suspend fun withCompositions(block: suspend (Composition, Composition, suspend () -> Unit) -> Unit) { + val clock = BroadcastFrameClock() + withContext(clock) { + val recomposer = Recomposer(coroutineContext) + val runner = launch { recomposer.runRecomposeAndApplyChanges() } + val frames = + launch { + while (isActive) { + clock.sendFrame(System.nanoTime()) + delay(1) + } + } + val owner = Composition(NoNodes(), recomposer) + val otherWindow = Composition(NoNodes(), recomposer) + + suspend fun settle() { + withTimeout(5_000) { + // Several frames also catch a feedback loop that merely passed through + // the expected state before opening and closing again. + repeat(5) { + Snapshot.sendApplyNotifications() + delay(10) + recomposer.awaitIdle() + } + } + } + try { + block(owner, otherWindow, ::settle) + } finally { + owner.dispose() + otherWindow.dispose() + recomposer.cancel() + runner.cancelAndJoin() + frames.cancelAndJoin() + } + } + } + + private class NoNodes : AbstractApplier(Unit) { + override fun insertTopDown( + index: Int, + instance: Unit, + ) = Unit + + override fun insertBottomUp( + index: Int, + instance: Unit, + ) = Unit + + override fun remove( + index: Int, + count: Int, + ) = Unit + + override fun move( + from: Int, + to: Int, + count: Int, + ) = Unit + + override fun onClear() = Unit + } +} From 4b83b6b1b83c634809a7f7655356e5c4b724ef39 Mon Sep 17 00:00:00 2001 From: Vidhaankhare16 <142911723+Vidhaankhare16@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:52:07 +0530 Subject: [PATCH 39/49] fix(run): make run-configuration disambiguation work on Windows (#653) * fix(run): make run-configuration disambiguation work on Windows makeNamesUnique and makeStoredNamesUnique split RunConfiguration.filePath on a literal "/", but a stored filePath is an OS-native absolute path: DesktopMainFunctionDetector assigns file.absolutePath, which is backslash-separated on Windows. split("/") yields one part there, the parts.size >= 2 guard never passes, and two configurations with the same name in different directories stay byte-identical in the Run dropdown. Split on both separators, and take the project name from extractFileName() instead of substringAfterLast('/'). plugin-path-utils documents itself as the single source of truth for path utilities, and RunConfiguration.toShortNameWithProject already builds the initial label with it; this file was the one place re-deriving those values with a hardcoded separator. The project-name change also closes a latent leak: substringAfterLast('/') returns the whole absolute path on Windows, so the label would have read "main (app/Main.kt [C:\Users\\myproject])" once the split was fixed. Both functions become internal so the tests can drive them directly. * fix(run): keep the project bracket and drop empty path segments Addresses the review on 03ba543b. makeStoredNamesUnique replaced the whole trailing "(...)" group, so a stored name of "main (Main.kt [myproject])" became "main (app/Main.kt)" and lost the project bracket that makeNamesUnique rebuilds. That asymmetry is pre-existing on POSIX, but the branch never executed on Windows before this change, so the fix above is what makes it reachable there. loadSettingsSync assigns the rewritten names to _currentSettings and the next saveSettings persists them, so the loss is not display-only. The bracket is now carried across. Both splits drop empty segments, matching DesktopMainFunctionDetector .detectModuleName. A doubled separator can reach these functions through a hand-edited run-configurations.json, and without the filter takeLast(2) picks up the empty segment and labels it "/Main.kt". The filter subsumes the leading separator trim, which is removed rather than left as dead belt-and-braces. The project name is now read from projectPath.trimEnd('/', '\'), so a project path with a trailing separator still yields a bracket instead of dropping it. Both regexes move to object-level vals: the trailing-group pattern was duplicated across the two functions, and neither needs recompiling per element. Tests go from 10 to 16, adding bracketed stored names on both platforms, a doubled separator in each function, idempotency of the stored rewrite, and a projectPath that is not an exact prefix of filePath. --- .../run/DesktopRunConfigurationManager.kt | 32 +- .../run/RunConfigurationNameSeparatorTest.kt | 290 ++++++++++++++++++ 2 files changed, 313 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/run/RunConfigurationNameSeparatorTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/run/DesktopRunConfigurationManager.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/run/DesktopRunConfigurationManager.kt index 8438041ab3..3055e20280 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/run/DesktopRunConfigurationManager.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/run/DesktopRunConfigurationManager.kt @@ -1,6 +1,7 @@ package ai.rever.boss.run import ai.rever.boss.plugin.pathutils.BossDirectories +import ai.rever.boss.utils.extractFileName import ai.rever.boss.utils.logging.BossLogger import ai.rever.boss.utils.logging.LogCategory import kotlinx.coroutines.Dispatchers @@ -24,6 +25,12 @@ actual object RunConfigurationManager { ignoreUnknownKeys = true } + /** The trailing " (...)" group of a configuration name, which disambiguation rewrites. */ + private val trailingGroupRegex = Regex("\\([^)]+\\)$") + + /** The " [Project]" part inside that group, present when the name came from a project scan. */ + private val trailingProjectRegex = Regex("( \\[[^\\]]*])\\)$") + private val detector = DesktopMainFunctionDetector() private val _currentSettings = MutableStateFlow(RunConfigurationSettings()) @@ -96,7 +103,7 @@ actual object RunConfigurationManager { /** * Make stored configuration names unique using parent directory context. */ - private fun makeStoredNamesUnique(configs: List): List { + internal fun makeStoredNamesUnique(configs: List): List { val nameGroups = configs.groupBy { it.name } return configs.map { config -> @@ -104,12 +111,19 @@ actual object RunConfigurationManager { if (group.size <= 1) { config } else { - // Add parent directory to make unique - val parts = config.filePath.split("/") + // Add parent directory to make unique. A stored filePath is an OS-native + // absolute path (File.absolutePath), so split on both separators. Empty segments + // are dropped the way DesktopMainFunctionDetector.detectModuleName drops them: + // this path is read from run-configurations.json, which is hand-editable, and a + // doubled separator would otherwise put "" into takeLast(2) and label it "/Main.kt". + val parts = config.filePath.split('/', '\\').filter { it.isNotEmpty() } val uniqueName = if (parts.size >= 2) { val parentAndFile = parts.takeLast(2).joinToString("/") - config.name.replace(Regex("\\([^)]+\\)$")) { "($parentAndFile)" } + // Keep any " [Project]" the stored name already carries: the trailing-group + // regex would otherwise consume it, and makeNamesUnique rebuilds it. + val projectSuffix = trailingProjectRegex.find(config.name)?.groupValues?.get(1) ?: "" + config.name.replace(trailingGroupRegex) { "($parentAndFile$projectSuffix)" } } else { config.name } @@ -158,13 +172,13 @@ actual object RunConfigurationManager { * E.g., two "main (Main.kt [Project])" become "main (app/Main.kt [Project])" and "main (lib/Main.kt [Project])" * Preserves the project name in brackets if present. */ - private fun makeNamesUnique( + internal fun makeNamesUnique( configs: List, projectPath: String, ): List { // Group by name to find duplicates val nameGroups = configs.groupBy { it.name } - val projectName = projectPath.substringAfterLast('/').takeIf { it.isNotBlank() } + val projectName = projectPath.trimEnd('/', '\\').extractFileName().takeIf { it.isNotBlank() } return configs.map { config -> val group = nameGroups[config.name] ?: return@map config @@ -172,14 +186,14 @@ actual object RunConfigurationManager { config } else { // Add parent directory to make unique, preserving project name - val relativePath = config.filePath.removePrefix(projectPath).removePrefix("/") - val parts = relativePath.split("/") + val relativePath = config.filePath.removePrefix(projectPath) + val parts = relativePath.split('/', '\\').filter { it.isNotEmpty() } val uniqueName = if (parts.size >= 2) { // Include parent directory: "main (parent/Main.kt [Project])" val parentAndFile = parts.takeLast(2).joinToString("/") val projectSuffix = if (projectName != null) " [$projectName]" else "" - config.name.replace(Regex("\\([^)]+\\)$")) { "($parentAndFile$projectSuffix)" } + config.name.replace(trailingGroupRegex) { "($parentAndFile$projectSuffix)" } } else { config.name } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/run/RunConfigurationNameSeparatorTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/run/RunConfigurationNameSeparatorTest.kt new file mode 100644 index 0000000000..288f437533 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/run/RunConfigurationNameSeparatorTest.kt @@ -0,0 +1,290 @@ +package ai.rever.boss.run + +import ai.rever.boss.plugin.run.Language +import ai.rever.boss.plugin.run.RunConfiguration +import ai.rever.boss.plugin.run.RunConfigurationType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Locks down the separator handling in run-configuration name disambiguation. + * + * A stored [RunConfiguration.filePath] is an OS-native absolute path, because + * DesktopMainFunctionDetector assigns `file.absolutePath`. On Windows that is + * backslash-separated, so neither disambiguation pass may assume a forward slash. + * + * Both path shapes are exercised on every CI leg: these are pure string functions + * with no filesystem access, so the Windows expectations are just as meaningful on + * a Linux runner as on a Windows one. + */ +class RunConfigurationNameSeparatorTest { + private fun config( + name: String, + filePath: String, + ) = RunConfiguration( + id = filePath, + name = name, + type = RunConfigurationType.MAIN_FUNCTION, + filePath = filePath, + lineNumber = 1, + language = Language.KOTLIN, + command = "", + workingDirectory = "", + ) + + @Test + fun `windows paths disambiguate two same-named configurations`() { + val project = """C:\Users\dev\myproject""" + val configs = + listOf( + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals( + 2, + names.toSet().size, + "Disambiguation is the whole purpose of makeNamesUnique, but on Windows paths " + + "both entries stayed identical: $names", + ) + assertTrue(names.any { it.contains("app/Main.kt") }, "Expected the app parent in $names") + assertTrue(names.any { it.contains("lib/Main.kt") }, "Expected the lib parent in $names") + } + + @Test + fun `windows label carries the project name, not the absolute project path`() { + val project = """C:\Users\dev\myproject""" + val configs = + listOf( + config("main (Main.kt)", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt)", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + names.forEach { name -> + assertFalse( + name.contains("""C:\Users\dev"""), + "A run-configuration label must not embed the absolute project path: $name", + ) + } + assertTrue(names.all { it.contains("[myproject]") }, "Expected the project name in $names") + } + + /** + * `removePrefix` is exact, so a projectPath differing from filePath in drive-letter case does + * not match and the relativisation is a no-op. `takeLast(2)` of the absolute path still yields + * a parent/file label, so the fallback cannot leak the full path either. + */ + @Test + fun `a project path that is not an exact prefix still cannot leak the absolute path`() { + val project = """c:\Users\dev\myproject""" + val configs = + listOf( + config("main (Main.kt)", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt)", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + @Test + fun `posix paths keep their existing disambiguation`() { + val project = "/home/dev/myproject" + val configs = + listOf( + config("main (Main.kt [myproject])", "/home/dev/myproject/app/Main.kt"), + config("main (Main.kt [myproject])", "/home/dev/myproject/lib/Main.kt"), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + @Test + fun `a single configuration is never renamed`() { + val project = """C:\Users\dev\myproject""" + val configs = listOf(config("main (Main.kt)", """C:\Users\dev\myproject\app\Main.kt""")) + + assertEquals(configs, RunConfigurationManager.makeNamesUnique(configs, project)) + } + + /** + * A file directly in the project root has no parent segment to disambiguate with, so its name + * must be left alone. The empty-segment filter is what makes that hold: the relative path keeps + * its leading separator, and without the filter it splits to a leading empty segment, clears + * the `size >= 2` guard on it and produces a label of "/Main.kt". + */ + @Test + fun `a windows file at the project root keeps its name`() { + val project = """C:\Users\dev\myproject""" + val configs = + listOf( + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\Main.kt"""), + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\app\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (Main.kt [myproject])", "main (app/Main.kt [myproject])"), names) + } + + @Test + fun `a posix file at the project root keeps its name`() { + val project = "/home/dev/myproject" + val configs = + listOf( + config("main (Main.kt [myproject])", "/home/dev/myproject/Main.kt"), + config("main (Main.kt [myproject])", "/home/dev/myproject/app/Main.kt"), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (Main.kt [myproject])", "main (app/Main.kt [myproject])"), names) + } + + /** extractFileName and the split both accept either separator, so a mixed path works too. */ + @Test + fun `mixed separators are handled`() { + val project = """C:/Users\dev/myproject""" + val configs = + listOf( + config("main (Main.kt)", """C:/Users\dev/myproject\app/Main.kt"""), + config("main (Main.kt)", """C:/Users\dev/myproject/lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + /** A trailing separator on the project path must still yield the project name. */ + @Test + fun `a project path with a trailing separator still yields the project name`() { + val project = """C:\Users\dev\myproject\""" + val configs = + listOf( + config("main (Main.kt)", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt)", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + /** + * A doubled interior separator reaches these functions through a hand-edited + * run-configurations.json. Without the empty-segment filter takeLast(2) picks up the empty + * segment and the label becomes "/Main.kt". + */ + @Test + fun `a doubled separator in a scanned path does not produce an empty segment`() { + val project = """C:\p""" + val configs = + listOf( + config("main (Main.kt)", """C:\p\app\\Main.kt"""), + config("main (Main.kt)", """C:\p\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeNamesUnique(configs, project).map { it.name } + + assertEquals(listOf("main (app/Main.kt [p])", "main (lib/Main.kt [p])"), names) + } + + @Test + fun `stored windows names disambiguate on reload`() { + val configs = + listOf( + config("main (Main.kt)", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt)", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeStoredNamesUnique(configs).map { it.name } + + assertEquals(listOf("main (app/Main.kt)", "main (lib/Main.kt)"), names) + } + + @Test + fun `stored posix names keep their existing disambiguation`() { + val configs = + listOf( + config("main (Main.kt)", "/home/dev/myproject/app/Main.kt"), + config("main (Main.kt)", "/home/dev/myproject/lib/Main.kt"), + ) + + val names = RunConfigurationManager.makeStoredNamesUnique(configs).map { it.name } + + assertEquals(listOf("main (app/Main.kt)", "main (lib/Main.kt)"), names) + } + + /** + * Stored names are toShortNameWithProject output, so they carry a bracketed project name. The + * trailing-group regex would consume that bracket, so makeStoredNamesUnique puts it back for + * parity with makeNamesUnique. loadSettingsSync assigns these names to _currentSettings and + * the next saveSettings persists them, so dropping it would not be display-only. + */ + @Test + fun `stored windows names keep the bracketed project suffix`() { + val configs = + listOf( + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeStoredNamesUnique(configs).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + @Test + fun `stored posix names keep the bracketed project suffix`() { + val configs = + listOf( + config("main (Main.kt [myproject])", "/home/dev/myproject/app/Main.kt"), + config("main (Main.kt [myproject])", "/home/dev/myproject/lib/Main.kt"), + ) + + val names = RunConfigurationManager.makeStoredNamesUnique(configs).map { it.name } + + assertEquals(listOf("main (app/Main.kt [myproject])", "main (lib/Main.kt [myproject])"), names) + } + + @Test + fun `a doubled separator in a stored path does not produce an empty segment`() { + val configs = + listOf( + config("main (Main.kt)", """C:\p\app\\Main.kt"""), + config("main (Main.kt)", """C:\p\lib\Main.kt"""), + ) + + val names = RunConfigurationManager.makeStoredNamesUnique(configs).map { it.name } + + assertEquals(listOf("main (app/Main.kt)", "main (lib/Main.kt)"), names) + } + + /** + * makeStoredNamesUnique runs on every launch and loadSettingsSync persists its output, so a + * non-idempotent rewrite would make labels drift on each start. + */ + @Test + fun `stored name rewriting is idempotent`() { + val configs = + listOf( + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\app\Main.kt"""), + config("main (Main.kt [myproject])", """C:\Users\dev\myproject\lib\Main.kt"""), + ) + + val once = RunConfigurationManager.makeStoredNamesUnique(configs) + val twice = RunConfigurationManager.makeStoredNamesUnique(once) + + assertEquals(once.map { it.name }, twice.map { it.name }) + } +} From 35dfe48c6e8da5790d72e8c27db4c41a3941dc8e Mon Sep 17 00:00:00 2001 From: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:53:27 +0530 Subject: [PATCH 40/49] Gate JxBrowser auto-release on the compile-classpath modules (#678) * Gate JxBrowser auto-release on the compile-classpath modules The autorelease watcher's artifact-readiness probe only checked the core jxbrowser jar and the platform binaries. But the desktop compile classpath also needs jxbrowser-compose and jxbrowser-swing (plus jxbrowser-kotlin, transitive of compose), each published as its own Maven module whose propagation can lag the binaries. When JxBrowser 9.5.1 landed, the binaries were resolvable but those modules were not, so the gate passed, a build was dispatched, and every branding matrix job failed at desktopCompileClasspath with: Could not find com.teamdev.jxbrowser:jxbrowser-compose:9.5.1 Add the compile-classpath modules to the probe so a version is not dispatched until every artifact the branding build actually resolves is available. Fixes #667 * test: cover missing JxBrowser compile modules in release gate --------- Co-authored-by: Shivang --- .github/scripts/check-jxbrowser-release.sh | 12 ++++++++++++ scripts/test/test-jxbrowser-autorelease.sh | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/scripts/check-jxbrowser-release.sh b/.github/scripts/check-jxbrowser-release.sh index 4af6c04c28..ab7c2be83d 100755 --- a/.github/scripts/check-jxbrowser-release.sh +++ b/.github/scripts/check-jxbrowser-release.sh @@ -59,8 +59,20 @@ probe_required_artifacts() { # Metadata can lead platform artifacts briefly. HEAD is intentional: a range # request could download an entire Chromium JAR if an intermediary ignored it. # Windows ARM64 is optional in the release matrix, so it is not a hard gate. + # + # jxbrowser-compose and jxbrowser-swing are on the desktop compile classpath + # (composeApp/build.gradle.kts), and jxbrowser-kotlin is pulled in transitively + # by jxbrowser-compose. All three publish as their own Maven modules, so their + # propagation can lag the platform binaries. Probing only the binaries let + # JxBrowser 9.5.1 dispatch while those modules were still unresolvable, and the + # branding build failed at `desktopCompileClasspath` with "Could not find + # com.teamdev.jxbrowser:jxbrowser-compose:9.5.1" (BossConsole#667). Gate on the + # compile-classpath modules too, not just the binaries. local artifacts=( jxbrowser + jxbrowser-compose + jxbrowser-swing + jxbrowser-kotlin jxbrowser-linux64 jxbrowser-linux64-arm jxbrowser-mac diff --git a/scripts/test/test-jxbrowser-autorelease.sh b/scripts/test/test-jxbrowser-autorelease.sh index a90d4f1b1a..41f4fadcc8 100755 --- a/scripts/test/test-jxbrowser-autorelease.sh +++ b/scripts/test/test-jxbrowser-autorelease.sh @@ -20,6 +20,8 @@ if [[ "$*" == *"maven-metadata.xml"* ]]; then printf '%s\n' \ "${MOCK_VERSION:-9.4.0}" fi +elif [[ -n "${MOCK_MISSING_ARTIFACT:-}" && "$*" == *"/${MOCK_MISSING_ARTIFACT}/"* ]]; then + exit 22 elif [[ "${MOCK_ARTIFACT_READY:-true}" != "true" ]]; then exit 22 elif [[ "$*" != *"--head"* ]]; then @@ -308,4 +310,18 @@ if verify_chromium_release_assets 'first.zip' 'Test release' > "$test_dir/verify fi echo "✓ missing_release_asset" + +for module in jxbrowser-compose jxbrowser-swing jxbrowser-kotlin; do + export MOCK_MISSING_ARTIFACT="$module" + run_watch_case "missing-$module" false artifact_not_ready true '[[]]' '[]' + assert_output "$test_dir/missing-$module.output" "blocked_artifact=$module" + if PATH="$mock_bin:$PATH" CHECK_MODE=preflight TARGET_VERSION=9.4.0 MOCK_RELEASE_PAGES='[[]]' SUPABASE_SERVICE_ROLE_KEY=test-key REQUIRED_ASSETS_FILE="$assets_file" GITHUB_OUTPUT="$test_dir/preflight-$module.output" bash "$detector" > "$test_dir/preflight-$module.log" 2>&1; then + echo "FAILED: preflight accepted absent $module" >&2 + exit 1 + fi + assert_output "$test_dir/preflight-$module.output" "blocked_artifact=$module" + echo "✓ preflight missing $module rejected" + unset MOCK_MISSING_ARTIFACT +done + echo "All JxBrowser release automation tests passed." From 69a6b76dc48145dd47d3462195938f300c47f4b0 Mon Sep 17 00:00:00 2001 From: Vidhaankhare16 <142911723+Vidhaankhare16@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:57:34 +0530 Subject: [PATCH 41/49] feat(cli): report workspace health in boss status and add boss doctor (#579) * feat(cli): report workspace health in boss status and add boss doctor boss status --json gains an additive health object built from state BOSS already keeps: sandbox watchdog disables and plugin health rows from #454, a browser engine that is missing, failed or unresponsive, and MCP kill-switch and policy faults. boss doctor prints the same report, one line per finding with a suggested next step, and exits 2 while anything is reported. Nothing is changed by either command. doctor is registered in createBossCLI, the headless gate in CliBootstrap.isHeadlessCli, OsOpenArguments.CLI_SUBCOMMANDS and the three launchers. OsOpenArgumentsTest now checks CLI_SUBCOMMANDS against createBossCLI().registeredSubcommands(), and CliBootstrapTest pins doctor as a headless command. Refs #418 * fix(cli): keep health inspection read-only and qualify unchecked reports * fix(cli): contain each window's plugin health source and report partial coverage WorkspaceHealthSources.pluginSnapshots() mapped every registered window in one expression, so one source that threw propagated out and the collector's per-area try turned the whole plugins area into unchecked. A watchdog-stopped plugin in window A disappeared whenever window B's source failed, which is exactly the case the feature exists to report. Each window source is now contained on its own. Every snapshot that could be read is kept, failures are counted, and each failure is logged through BossLogger with LogCategory.SYSTEM the way the collector already logs an unreadable area. unchecked still means nothing could be read at all. A new additive partial set says an area was read from several sources and some of them failed, so the findings listed are real but do not cover the area. An area is never in both. boss doctor prints a "Partially checked:" line and stops saying "No problems found."; boss status appends "partially checked: plugins" to its Health line. Today only plugins can be partial, because it is the only area with one source per window. partial is additive on the wire: a reader that does not know the field sees what it saw before, and both CLI commands read an absent partial as empty, so an older CLI against a newer BOSS and the reverse both still parse. WorkspaceHealthSourcesTest now registers a failing source beside one that reports a stopped plugin and asserts through the real collector that the stopped plugin is still reported and the area is declared partial. Refs #418 --------- Co-authored-by: Shivang --- composeApp/detekt-baseline.xml | 2 +- .../rever/boss/app/BossAppStartupEffects.kt | 10 + .../plugin/PluginHealthCenterDialog.kt | 16 +- .../components/plugin/PluginHealthSnapshot.kt | 36 +++ .../ai/rever/boss/health/WorkspaceHealth.kt | 241 +++++++++++++++ .../rever/boss/health/WorkspaceHealthJson.kt | 39 +++ .../boss/health/WorkspaceHealthSources.kt | 91 ++++++ .../kotlin/ai/rever/boss/cli/BossCommand.kt | 2 + .../ai/rever/boss/cli/BossDoctorCommand.kt | 137 +++++++++ .../boss/config/ChromiumAutoDownloader.kt | 28 +- .../boss/health/WorkspaceHealthCollector.kt | 107 +++++++ .../rever/boss/plugin/browser/FluckEngine.kt | 4 + .../ai/rever/boss/startup/CliBootstrap.kt | 4 +- .../ai/rever/boss/utils/OsOpenArguments.kt | 2 +- .../rever/boss/utils/SingleInstanceManager.kt | 13 +- .../ai/rever/boss/cli/BossDoctorCliTest.kt | 252 ++++++++++++++++ .../boss/config/ChromiumAutoDownloaderTest.kt | 27 ++ .../health/WorkspaceHealthCollectorTest.kt | 97 ++++++ .../boss/health/WorkspaceHealthReportTest.kt | 283 ++++++++++++++++++ .../boss/health/WorkspaceHealthSourcesTest.kt | 94 ++++++ .../ai/rever/boss/startup/CliBootstrapTest.kt | 1 + .../rever/boss/utils/OsOpenArgumentsTest.kt | 28 +- .../boss/utils/StatusHealthWiringTest.kt | 41 +++ docs/CLI.md | 43 +++ docs/mcp-agentless-operators.md | 5 + scripts/boss | 3 +- scripts/boss.bat | 2 + scripts/boss.ps1 | 3 +- 28 files changed, 1558 insertions(+), 53 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthSnapshot.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealth.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthJson.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthSources.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/health/WorkspaceHealthCollector.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/BossDoctorCliTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthCollectorTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthReportTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthSourcesTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/StatusHealthWiringTest.kt diff --git a/composeApp/detekt-baseline.xml b/composeApp/detekt-baseline.xml index 419dac4c7d..e539a373d6 100644 --- a/composeApp/detekt-baseline.xml +++ b/composeApp/detekt-baseline.xml @@ -1094,7 +1094,7 @@ ReturnCount:CLISecurityValidator.kt$CLISecurityValidator$fun isValidOpenTargetPath(path: String): Boolean ReturnCount:CLISecurityValidator.kt$CLISecurityValidator$fun isValidPath(path: String): Boolean ReturnCount:CLISecurityValidator.kt$CLISecurityValidator$fun normalizeAndValidateUrl(url: String): String? - ReturnCount:ChromiumAutoDownloader.kt$ChromiumAutoDownloader$fun isChromiumInstalled(): Boolean + ReturnCount:ChromiumAutoDownloader.kt$ChromiumAutoDownloader$internal fun chromiumInstalledAt( dir: Path = getChromiumDir(), requiredVersion: String = effectiveVersion, isMac: Boolean = System.getProperty("os.name").lowercase().contains("mac"), repairAttempted: () -> Boolean = ::repairAlreadyAttempted, recordRepair: () -> Unit, ): Boolean ReturnCount:ChromiumAutoDownloader.kt$ChromiumAutoDownloader$internal fun promotePendingInstall( pending: File, target: File, backup: File, ) ReturnCount:ChromiumReleaseSource.kt$ChromiumReleaseResolver.Companion$private fun comparePreRelease( a: List<String>, b: List<String>, ): Int ReturnCount:CrossDeviceAuthService.kt$CrossDeviceAuthService$suspend fun handleCrossDeviceAuthentication( exception: CrossDeviceAuthenticationRequired, onAuthenticationComplete: suspend (PasskeyAuthenticationResult) -> Result<Unit>, ): Result<Unit> diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppStartupEffects.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppStartupEffects.kt index ef222f3dbe..4cf2c42a54 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppStartupEffects.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppStartupEffects.kt @@ -2,6 +2,7 @@ package ai.rever.boss.app import ai.rever.boss.components.plugin.DefaultPlugin import ai.rever.boss.components.plugin.PluginUpdateRegistry +import ai.rever.boss.components.plugin.currentPluginHealth import ai.rever.boss.components.plugin.tab_types.fluck.FluckTabInfo import ai.rever.boss.components.plugin.tab_types.registerPanelHostTab import ai.rever.boss.components.registery.PanelComponentStoreRegistry @@ -26,6 +27,7 @@ import ai.rever.boss.components.workspaces.tabListChanges import ai.rever.boss.components.workspaces.workspaceManager import ai.rever.boss.consumePendingInitialProject import ai.rever.boss.consumePendingInitialTab +import ai.rever.boss.health.WorkspaceHealthSources import ai.rever.boss.performance.BrowserTabInfo import ai.rever.boss.performance.EditorTabResourceInfo import ai.rever.boss.performance.PerformanceState @@ -445,7 +447,15 @@ internal fun BossAppStartupEffects(state: BossAppState) { ai.rever.boss.services.editor.EditorAPIAccess .initialize(plugin) + // Let `boss status` and `boss doctor` report this window's plugin health. DefaultPlugin's + // init already created the manager, so a health query reads it and creates nothing. + val pluginHealthSource = { currentPluginHealth(plugin.dynamicPluginManager) } + WorkspaceHealthSources.registerPlugins(windowId, pluginHealthSource) + onDispose { + // Unregister first, before the plugin is disposed, so a health query never reads a disposed manager. + WorkspaceHealthSources.unregisterPlugins(windowId, pluginHealthSource) + // NOTE: Browser disposal moved to main.kt onCloseRequest handler // Browsers must be disposed BEFORE Compose disposal begins, not during it // See main.kt onCloseRequest for the disposeAllBrowsersBlocking() call diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthCenterDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthCenterDialog.kt index abdee8ee85..3d09f76c24 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthCenterDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthCenterDialog.kt @@ -284,21 +284,7 @@ private fun kotlinx.coroutines.CoroutineScope.launchHealthAction( private fun currentHealthAction( manager: DynamicPluginManager, pluginId: String, -): PluginHealthAction? { - val states = manager.pluginStates.value - val user = AuthStateManager.currentUser.value - return healthRowsWithSandboxDisables( - pluginHealthRows( - pluginStates = states, - loadGates = PluginLoadGateRegistry.gates.value, - crashedPluginIds = states.keys.filterTo(mutableSetOf()) { PluginCrashRegistry.hasCrashed(it) }, - inaccessiblePluginIds = - healthInaccessiblePluginIds(states, user?.isAdmin == true, user?.permissions?.toSet() ?: emptySet()), - incompatiblePluginIds = PluginCrashRegistry.incompatiblePlugins.value, - ), - states.keys.filterTo(mutableSetOf()) { manager.sandboxManager.isPluginDisabled(it) }, - ).firstOrNull { it.pluginId == pluginId }?.action -} +): PluginHealthAction? = currentPluginHealth(manager).rows.firstOrNull { it.pluginId == pluginId }?.action @Composable private fun PluginHealthRowCard( diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthSnapshot.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthSnapshot.kt new file mode 100644 index 0000000000..4015784978 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/PluginHealthSnapshot.kt @@ -0,0 +1,36 @@ +package ai.rever.boss.components.plugin + +import ai.rever.boss.plugin.sandbox.ui.PluginCrashRegistry +import ai.rever.boss.services.auth.AuthStateManager + +/** + * One window's plugin health, read from current values rather than composition state. + * + * [rows] are exactly what the Plugin Health & Recovery center shows for that window. + */ +internal data class PluginHealthSnapshot( + val rows: List, + /** Plugins this window's sandbox watchdog stopped after repeated failures. */ + val sandboxDisabledPluginIds: Set, +) + +/** + * Snapshot [manager]'s plugin health with one read of each value and no composition, so it is safe + * to call off the UI thread. The Plugin Health & Recovery center uses it to re-check an action + * before running it; the headless workspace health report uses it for every open window. + */ +internal fun currentPluginHealth(manager: DynamicPluginManager): PluginHealthSnapshot { + val states = manager.pluginStates.value + val user = AuthStateManager.currentUser.value + val rows = + pluginHealthRows( + pluginStates = states, + loadGates = PluginLoadGateRegistry.gates.value, + crashedPluginIds = states.keys.filterTo(mutableSetOf()) { PluginCrashRegistry.hasCrashed(it) }, + inaccessiblePluginIds = + healthInaccessiblePluginIds(states, user?.isAdmin == true, user?.permissions?.toSet() ?: emptySet()), + incompatiblePluginIds = PluginCrashRegistry.incompatiblePlugins.value, + ) + val sandboxDisabled = states.keys.filterTo(mutableSetOf()) { manager.sandboxManager.isPluginDisabled(it) } + return PluginHealthSnapshot(healthRowsWithSandboxDisables(rows, sandboxDisabled), sandboxDisabled) +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealth.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealth.kt new file mode 100644 index 0000000000..3796010d6b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealth.kt @@ -0,0 +1,241 @@ +package ai.rever.boss.health + +import ai.rever.boss.components.plugin.PluginHealthSnapshot +import ai.rever.boss.components.plugin.PluginHealthStatus +import ai.rever.boss.mcp.McpKillSwitchFault +import ai.rever.boss.mcp.McpPolicyFault + +/** Where a health finding comes from. [wireName] is the value `boss status --json` sends. */ +internal enum class HealthArea( + val wireName: String, +) { + PLUGINS("plugins"), + BROWSER("browser"), + MCP("mcp"), +} + +/** How much of the workspace a finding takes away. Declared most severe first. */ +internal enum class HealthSeverity( + val wireName: String, +) { + /** + * Something that should work is broken and a whole capability is gone: a browser engine that + * failed, or every MCP tool. + */ + CRITICAL("critical"), + + /** Something narrower: one plugin, one setting that was not saved, or an engine not downloaded yet. */ + WARNING("warning"), +} + +/** Stable identifiers scripts can match on. Summaries are for people and may be reworded. */ +internal object HealthCodes { + const val PLUGIN_STOPPED = "plugin_stopped_after_failures" + const val PLUGIN_NEEDS_ATTENTION = "plugin_needs_attention" + const val BROWSER_ENGINE_NOT_INSTALLED = "browser_engine_not_installed" + const val BROWSER_ENGINE_UNAVAILABLE = "browser_engine_unavailable" + const val BROWSER_ENGINE_UNRESPONSIVE = "browser_engine_unresponsive" + const val MCP_TOOLS_WITHHELD = "mcp_tools_withheld" + const val MCP_TOOL_SETTING_NOT_SAVED = "mcp_tool_setting_not_saved" + const val MCP_POLICY_UNREADABLE = "mcp_policy_unreadable" + const val MCP_POLICY_NOT_SAVED = "mcp_policy_not_saved" +} + +/** One problem the workspace is known to have, with a suggested next step when there is one. */ +internal data class HealthFinding( + val area: HealthArea, + val severity: HealthSeverity, + val code: String, + val summary: String, + val subject: String? = null, + val remedy: String? = null, +) + +/** + * The workspace health report. + * + * Only [findings] make the workspace degraded. [unchecked] areas could not be read at all, so they + * are reported as neither healthy nor degraded. [partial] areas are read from more than one source + * and at least one of those sources failed: the findings listed for them are real, but they do not + * cover the whole area, so an empty result there is not a clean bill of health. + */ +internal data class WorkspaceHealthReport( + val findings: List, + val unchecked: Set = emptySet(), + val partial: Set = emptySet(), +) { + val degraded: Boolean get() = findings.isNotEmpty() +} + +internal sealed interface BrowserEngineHealth { + data object Healthy : BrowserEngineHealth + + /** No usable engine is installed yet. [reason] is the engine's own advice on what to do. */ + data class NotInstalled( + val reason: String, + ) : BrowserEngineHealth + + /** The engine could not start. [reason] is the engine's own operator-facing explanation. */ + data class Unavailable( + val reason: String, + ) : BrowserEngineHealth + + /** The engine stopped creating browsers and the wedge detector has spent its recycle budget. */ + data object Unresponsive : BrowserEngineHealth +} + +/** The MCP fault states the bottom status bar shows. */ +internal data class McpFaults( + val killSwitch: McpKillSwitchFault?, + val policy: McpPolicyFault?, +) + +/** + * What a report is built from. A null source could not be read and is reported as unchecked. + * + * [pluginSourceFailures] is how many window sources threw while [plugins] was being read. It is + * only meaningful beside a non-null [plugins]: when no window answered at all the area is unchecked, + * which already says nothing was read. + */ +internal data class WorkspaceHealthInputs( + val plugins: List?, + val browser: BrowserEngineHealth?, + val mcp: McpFaults?, + val pluginSourceFailures: Int = 0, +) + +private val findingOrder: Comparator = + compareBy({ it.severity.ordinal }, { it.area.ordinal }, { it.subject.orEmpty() }, { it.code }) + +/** + * Turn state BOSS already tracks into findings. Pure: no clock, no I/O, no side effects. + * + * Only problems count. A plugin the user disabled, or one the signed-in user may not access, is + * listed as unavailable by the Plugin Health & Recovery center, but it is not a degradation here. + */ +internal fun workspaceHealthReport(inputs: WorkspaceHealthInputs): WorkspaceHealthReport { + val findings = + buildList { + inputs.plugins?.let { addAll(pluginFindings(it)) } + inputs.browser?.let { health -> browserFinding(health)?.let { add(it) } } + inputs.mcp?.killSwitch?.let { add(killSwitchFinding(it)) } + inputs.mcp?.policy?.let { add(policyFinding(it)) } + } + val unchecked = + buildSet { + if (inputs.plugins == null) add(HealthArea.PLUGINS) + if (inputs.browser == null) add(HealthArea.BROWSER) + if (inputs.mcp == null) add(HealthArea.MCP) + } + val partial = + buildSet { + if (inputs.plugins != null && inputs.pluginSourceFailures > 0) add(HealthArea.PLUGINS) + } + return WorkspaceHealthReport(findings.sortedWith(findingOrder), unchecked, partial) +} + +/** + * A plugin the sandbox watchdog stopped is reported as that, once, however many windows stopped + * it. Other plugins needing attention are reported with the health center's own detail. + */ +private fun pluginFindings(snapshots: List): List { + val rows = snapshots.flatMap { it.rows } + val names = rows.associate { it.pluginId to it.displayName } + val stopped = snapshots.flatMapTo(sortedSetOf()) { it.sandboxDisabledPluginIds } + val stoppedFindings = + stopped.map { pluginId -> + HealthFinding( + area = HealthArea.PLUGINS, + severity = HealthSeverity.WARNING, + code = HealthCodes.PLUGIN_STOPPED, + summary = "Plugin '${names[pluginId] ?: pluginId}' was stopped after repeated failures.", + subject = pluginId, + remedy = "Reload it from Help > Plugin Health & Recovery in the affected window, or restart BOSS.", + ) + } + val attentionFindings = + rows + .filter { it.status == PluginHealthStatus.NEEDS_ATTENTION && it.pluginId !in stopped } + .distinctBy { it.pluginId to it.detail } + .map { row -> + HealthFinding( + area = HealthArea.PLUGINS, + severity = HealthSeverity.WARNING, + code = HealthCodes.PLUGIN_NEEDS_ATTENTION, + summary = "Plugin '${row.displayName}': ${row.detail}", + subject = row.pluginId, + remedy = "Open Help > Plugin Health & Recovery for this plugin's recovery options.", + ) + } + return stoppedFindings + attentionFindings +} + +private fun browserFinding(health: BrowserEngineHealth): HealthFinding? = + when (health) { + BrowserEngineHealth.Healthy -> { + null + } + + is BrowserEngineHealth.NotInstalled -> { + HealthFinding( + area = HealthArea.BROWSER, + severity = HealthSeverity.WARNING, + code = HealthCodes.BROWSER_ENGINE_NOT_INSTALLED, + summary = "No usable browser engine is installed, so browser tabs and browser tools cannot run.", + remedy = health.reason, + ) + } + + is BrowserEngineHealth.Unavailable -> { + HealthFinding( + area = HealthArea.BROWSER, + severity = HealthSeverity.CRITICAL, + code = HealthCodes.BROWSER_ENGINE_UNAVAILABLE, + summary = "The browser engine could not start: ${health.reason}", + remedy = "Resolve the cause above, then use Retry in a browser tab or restart BOSS.", + ) + } + + BrowserEngineHealth.Unresponsive -> { + HealthFinding( + area = HealthArea.BROWSER, + severity = HealthSeverity.CRITICAL, + code = HealthCodes.BROWSER_ENGINE_UNRESPONSIVE, + summary = "The browser engine stopped creating browsers and automatic recovery has stopped retrying.", + remedy = "Restart BOSS.", + ) + } + } + +/** + * The fault's own message already names the file and the recovery, so it is the summary. Only a + * disabled-tools file that cannot be read withholds every tool. Any other kill-switch fault is + * reported as a setting that could not be saved, deliberately without listing the fault types, so a + * type added later is still reported rather than breaking this mapping. + */ +private fun killSwitchFinding(fault: McpKillSwitchFault): HealthFinding = + if (fault is McpKillSwitchFault.PersistedSetUnreadable) { + HealthFinding(HealthArea.MCP, HealthSeverity.CRITICAL, HealthCodes.MCP_TOOLS_WITHHELD, fault.message) + } else { + HealthFinding( + area = HealthArea.MCP, + severity = HealthSeverity.WARNING, + code = HealthCodes.MCP_TOOL_SETTING_NOT_SAVED, + summary = fault.message, + subject = (fault as? McpKillSwitchFault.TogglePersistFailed)?.toolName, + ) + } + +/** As [killSwitchFinding]: only a policy file that cannot be read withholds every tool. */ +private fun policyFinding(fault: McpPolicyFault): HealthFinding = + if (fault is McpPolicyFault.PersistedPolicyUnreadable) { + HealthFinding(HealthArea.MCP, HealthSeverity.CRITICAL, HealthCodes.MCP_POLICY_UNREADABLE, fault.message) + } else { + HealthFinding( + area = HealthArea.MCP, + severity = HealthSeverity.WARNING, + code = HealthCodes.MCP_POLICY_NOT_SAVED, + summary = fault.message, + subject = (fault as? McpPolicyFault.PolicyPersistFailed)?.toolName, + ) + } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthJson.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthJson.kt new file mode 100644 index 0000000000..556785bf82 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthJson.kt @@ -0,0 +1,39 @@ +package ai.rever.boss.health + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray + +/** + * The `health` object carried by `boss status --json` and printed by `boss doctor --json`. + * + * Field names and the lowercase [HealthArea.wireName] and [HealthSeverity.wireName] values are a + * contract with scripts and agents: add fields, never rename them. An absent `subject` or `remedy` + * is omitted rather than sent as null. `partial` is additive: a reader that does not know it sees + * exactly what it saw before. + */ +internal fun WorkspaceHealthReport.toJson(): JsonObject = + buildJsonObject { + put("degraded", degraded) + putJsonArray("findings") { + for (finding in findings) { + addJsonObject { + put("area", finding.area.wireName) + put("severity", finding.severity.wireName) + put("code", finding.code) + put("summary", finding.summary) + finding.subject?.let { put("subject", it) } + finding.remedy?.let { put("remedy", it) } + } + } + } + putJsonArray("unchecked") { + for (area in unchecked.sortedBy { it.ordinal }) add(area.wireName) + } + putJsonArray("partial") { + for (area in partial.sortedBy { it.ordinal }) add(area.wireName) + } + } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthSources.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthSources.kt new file mode 100644 index 0000000000..b105a3c62c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/health/WorkspaceHealthSources.kt @@ -0,0 +1,91 @@ +package ai.rever.boss.health + +import ai.rever.boss.components.plugin.PluginHealthSnapshot +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory +import java.util.concurrent.ConcurrentHashMap + +/** + * What [WorkspaceHealthSources.pluginSnapshots] could read. + * + * [snapshots] holds one entry per window that answered, and [failedSources] counts the windows whose + * source threw. The two together are what lets the report say "these findings are real, but they do + * not cover every window" instead of dropping either half. + */ +internal data class PluginSnapshotRead( + val snapshots: List, + val failedSources: Int = 0, +) + +/** + * Where the app-wide health report finds each window's plugin health. + * + * Every window owns its own `DynamicPluginManager`, created with that window's `DefaultPlugin`, + * and nothing else enumerates them. A window registers a source when its `DefaultPlugin` is + * created and unregisters it before that plugin is disposed. + */ +internal object WorkspaceHealthSources { + private val pluginSources = ConcurrentHashMap PluginHealthSnapshot>() + private val logger = BossLogger.forComponent("WorkspaceHealth") + + fun registerPlugins( + windowId: String, + source: () -> PluginHealthSnapshot, + ) { + pluginSources[windowId] = source + } + + /** + * Remove [source] only while it is still the one registered for [windowId], so an effect that + * re-runs for the same window cannot remove the registration that replaced it. + */ + fun unregisterPlugins( + windowId: String, + source: () -> PluginHealthSnapshot, + ) { + pluginSources.remove(windowId, source) + } + + /** + * One snapshot per registered window that could be read. + * + * Each window source is contained on its own, because a window whose manager is mid-disposal + * must not hide a watchdog-stopped plugin in another window. A source that throws is logged and + * counted in [PluginSnapshotRead.failedSources]; every other window's snapshot is kept. + * LinkageError is caught alongside Exception for the same reason the collector catches it: a + * class missing from one window's plugin path must not take the whole query down. + */ + @Suppress("TooGenericExceptionCaught") + fun pluginSnapshots(): PluginSnapshotRead { + val snapshots = mutableListOf() + var failedSources = 0 + for ((windowId, source) in pluginSources) { + try { + snapshots += source() + } catch (e: Exception) { + failedSources++ + logUnreadable(windowId, e) + } catch (e: LinkageError) { + failedSources++ + logUnreadable(windowId, e) + } + } + return PluginSnapshotRead(snapshots, failedSources) + } + + /** For tests. */ + fun clear() { + pluginSources.clear() + } + + private fun logUnreadable( + windowId: String, + error: Throwable, + ) { + logger.warn( + LogCategory.SYSTEM, + "Plugin health source could not be read for one window", + mapOf("windowId" to windowId, "error" to (error.message ?: error::class.simpleName)), + ) + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt index 12d03f7566..af4a119781 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt @@ -191,6 +191,7 @@ class BossStatusCommand : CliktCommand(name = "status") { val heapPct = mem["heapPercent"]?.jsonPrimitive?.contentOrNull ?: "?" appendLine(" JVM Memory: ${used}MB / ${max}MB ($heapPct%)") } + healthSummaryOf(element)?.let { appendLine(" Health: $it") } } catch (_: Exception) { appendLine(rawJson) } @@ -565,6 +566,7 @@ fun createBossCLI(): BossCommand = BossFolderCommand(), BossTerminalCommand(), BossStatusCommand(), + BossDoctorCommand(), BossMcpCommand(), BossCompletionCommand(), ) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt new file mode 100644 index 0000000000..4e1288e01f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossDoctorCommand.kt @@ -0,0 +1,137 @@ +package ai.rever.boss.cli + +import ai.rever.boss.utils.SingleInstanceManager +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull + +/** `boss doctor` exit code: BOSS is running and reported at least one health finding. */ +internal const val EXIT_DEGRADED = 2 + +/** + * Reports workspace health from the running BOSS instance, for agents and scripts that cannot see + * its windows. Read-only: it only reads the `health` object that `boss status --json` carries. + * + * Exit codes: `0` no findings, `2` running but degraded, `1` not running, the query failed, or the + * running BOSS did not return a health report. + */ +class BossDoctorCommand : CliktCommand(name = "doctor") { + override fun help(context: Context) = "Reports problems in the running BOSS instance, with suggested next steps" + + val json by option("--json", help = "Output the health report as JSON").flag(default = false) + + override fun run() { + val rawStatus = SingleInstanceManager.queryStatus().getOrElse { fail("Error: ${it.message}") } + val health = + healthObjectOf(rawStatus) + ?: fail("Error: the running BOSS did not return a health report. Update BOSS to use boss doctor.") + echo(if (json) health.toString() else formatDoctorReport(health)) + if (health.isDegraded()) throw ProgramResult(EXIT_DEGRADED) + } + + private fun fail(message: String): Nothing { + echo(message, err = true) + throw ProgramResult(1) + } +} + +/** The `health` object of a `boss status --json` response, or null when there is none. */ +internal fun healthObjectOf(rawStatus: String): JsonObject? { + val status = + try { + Json.parseToJsonElement(rawStatus) as? JsonObject + } catch (_: SerializationException) { + null + } + return status?.get("health") as? JsonObject +} + +/** The human `boss doctor` report. Fields are read leniently so a newer BOSS can add to the object. */ +internal fun formatDoctorReport(health: JsonObject): String = + buildString { + appendLine("BOSS Doctor") + appendLine("-----------") + val findings = health.findings() + val unchecked = health.uncheckedAreas() + val partial = health.partialAreas() + if (findings.isEmpty()) { + appendLine( + when { + health.isDegraded() -> "BOSS reports degraded health without finding details." + unchecked.isNotEmpty() || partial.isNotEmpty() -> "No problems found in checked areas." + else -> "No problems found." + }, + ) + } else { + for (finding in findings) { + appendLine("[${finding.text("severity") ?: "problem"}] ${finding.text("summary").orEmpty()}") + finding.text("remedy")?.let { appendLine(" Suggested: $it") } + } + appendLine("${problemCount(findings.size)} found.") + } + if (unchecked.isNotEmpty()) appendLine("Not checked: ${unchecked.joinToString(", ")}") + if (partial.isNotEmpty()) { + appendLine( + "Partially checked: ${partial.joinToString(", ")}. Some sources could not be read, " + + "so problems there may be missing.", + ) + } + }.trimEnd() + +/** + * The value of the `Health:` line in `boss status`, or null for a BOSS that does not report health. + * + * An area that was only partly read qualifies the line the same way an unchecked one does, so a + * report that covers some windows and not others never reads as a clean bill of health. + */ +internal fun healthSummaryOf(status: JsonObject): String? { + val health = status["health"] as? JsonObject ?: return null + val count = health.findings().size + val unchecked = health.uncheckedAreas() + val partial = health.partialAreas() + val qualifiers = + buildList { + if (unchecked.isNotEmpty()) add("not checked: ${unchecked.joinToString(", ")}") + if (partial.isNotEmpty()) add("partially checked: ${partial.joinToString(", ")}") + } + val summary = + when { + count > 0 -> "${problemCount(count)} (run 'boss doctor')" + qualifiers.isEmpty() -> "OK" + else -> "No problems found in checked areas" + } + return if (qualifiers.isEmpty()) summary else "$summary; ${qualifiers.joinToString("; ")}" +} + +/** Whether this health object reports a degraded workspace. */ +internal fun JsonObject.isDegraded() = (get("degraded") as? JsonPrimitive)?.booleanOrNull ?: findings().isNotEmpty() + +private fun JsonObject.findings() = (get("findings") as? JsonArray).orEmpty().filterIsInstance() + +private fun JsonObject.uncheckedAreas(): List = areaNames("unchecked") + +/** Areas the running BOSS could read only in part. Absent on a BOSS that predates the field. */ +private fun JsonObject.partialAreas(): List = areaNames("partial") + +private fun JsonObject.areaNames(key: String): List = + (get(key) as? JsonArray).orEmpty().mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + +/** A field on one line. Fault messages can contain line breaks, and the report prints one line per item. */ +private fun JsonObject.text(key: String): String? = + (get(key) as? JsonPrimitive) + ?.contentOrNull + ?.lines() + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?.joinToString(" ") + +private fun problemCount(count: Int): String = if (count == 1) "1 problem" else "$count problems" diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/ChromiumAutoDownloader.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/ChromiumAutoDownloader.kt index 58040d4e40..8b19d64050 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/ChromiumAutoDownloader.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/config/ChromiumAutoDownloader.kt @@ -184,8 +184,18 @@ object ChromiumAutoDownloader { * Check if Chromium is already installed, valid, and matches the effective * engine version (Settings pin, else the bundled JxBrowser version). */ - fun isChromiumInstalled(): Boolean { - val dir = getChromiumDir() + fun isChromiumInstalled(): Boolean = chromiumInstalledAt(recordRepair = ::recordRepairAttempt) + + /** Check the cache without consuming the startup repair attempt. Safe for status queries. */ + internal fun isChromiumInstalledReadOnly(): Boolean = chromiumInstalledAt(recordRepair = {}) + + internal fun chromiumInstalledAt( + dir: Path = getChromiumDir(), + requiredVersion: String = effectiveVersion, + isMac: Boolean = System.getProperty("os.name").lowercase().contains("mac"), + repairAttempted: () -> Boolean = ::repairAlreadyAttempted, + recordRepair: () -> Unit, + ): Boolean { if (!dir.toFile().exists()) return false // Check executable.name exists (required by JxBrowser) @@ -200,13 +210,13 @@ object ChromiumAutoDownloader { } val installedVersion = versionFile.readText().trim() - if (installedVersion != effectiveVersion) { + if (installedVersion != requiredVersion) { logger.info( LogCategory.BROWSER, "Chromium version mismatch", mapOf( "installed" to installedVersion, - "required" to effectiveVersion, + "required" to requiredVersion, ), ) return false @@ -214,7 +224,7 @@ object ChromiumAutoDownloader { // On macOS, verify the executable has proper permissions // This catches cached Chromium from older versions that didn't set execute bit correctly - if (System.getProperty("os.name").lowercase().contains("mac")) { + if (isMac) { val executableName = executableNameFile.readText().trim() // executable.name holds the bundle name without its suffix (the branding // workflow writes `basename "$APP_BUNDLE" .app`), so the directory on @@ -233,18 +243,18 @@ object ChromiumAutoDownloader { // directory on EVERY launch and re-fetch ~160 MB forever, silently. // The marker lives outside the engine directory because a // re-download replaces that whole directory. - if (repairAlreadyAttempted()) { + if (repairAttempted()) { logger.warn( LogCategory.BROWSER, "Chromium still registers itself as a browser after a re-download; keeping it", - mapOf("version" to effectiveVersion), + mapOf("version" to requiredVersion), ) } else { - recordRepairAttempt() + recordRepair() logger.info( LogCategory.BROWSER, "Cached Chromium still registers itself as a browser, will re-download", - mapOf("version" to effectiveVersion), + mapOf("version" to requiredVersion), ) return false } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/health/WorkspaceHealthCollector.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/health/WorkspaceHealthCollector.kt new file mode 100644 index 0000000000..d3aa634bde --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/health/WorkspaceHealthCollector.kt @@ -0,0 +1,107 @@ +package ai.rever.boss.health + +import ai.rever.boss.config.ChromiumAutoDownloader +import ai.rever.boss.mcp.McpToolRegistryImpl +import ai.rever.boss.plugin.browser.EngineInitError +import ai.rever.boss.plugin.browser.FluckEngine +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory + +/** + * Reads every health source for `boss status` and `boss doctor`. + * + * Read-only by construction: each source is a value another part of BOSS already maintains. The + * sources are parameters so tests can replace them without starting a browser engine. + */ +internal class WorkspaceHealthCollector( + private val pluginSnapshots: () -> PluginSnapshotRead = WorkspaceHealthSources::pluginSnapshots, + private val browserHealth: () -> BrowserEngineHealth = ::currentBrowserEngineHealth, + private val mcpFaults: () -> McpFaults = ::currentMcpFaults, +) { + /** + * Never throws. A source that cannot be read is reported as unchecked, never as healthy. With no + * window open there is no plugin manager to read, so plugins are unchecked as well. + * + * Plugin health has one source per window and [WorkspaceHealthSources.pluginSnapshots] contains + * each of them separately, so plugins go unchecked only when no window answered. When some + * windows answered and others threw, their findings are kept and the area is reported partial + * rather than complete. + */ + fun collect(): WorkspaceHealthReport { + val plugins = read(HealthArea.PLUGINS, pluginSnapshots) + return workspaceHealthReport( + WorkspaceHealthInputs( + plugins = plugins?.snapshots?.takeIf { it.isNotEmpty() }, + browser = read(HealthArea.BROWSER, browserHealth), + mcp = read(HealthArea.MCP, mcpFaults), + pluginSourceFailures = plugins?.failedSources ?: 0, + ), + ) + } + + // A status query must still answer when one health source is broken, so each source is + // contained here. LinkageError is caught alongside Exception because a class missing from + // this path must not take `boss status` down with it; VirtualMachineError is left alone. + @Suppress("TooGenericExceptionCaught") + private fun read( + area: HealthArea, + source: () -> T, + ): T? = + try { + source() + } catch (e: Exception) { + unreadable(area, e) + } catch (e: LinkageError) { + unreadable(area, e) + } + + private fun unreadable( + area: HealthArea, + error: Throwable, + ): Nothing? { + logger.warn( + LogCategory.SYSTEM, + "Workspace health source could not be read", + mapOf("area" to area.wireName, "error" to (error.message ?: error::class.simpleName)), + ) + return null + } + + private companion object { + val logger = BossLogger.forComponent("WorkspaceHealth") + } +} + +/** + * The browser engine as the health report describes it. + * + * Whether an engine is installed is asked first, with the same check start-up uses before it offers + * the download, because a start-up error recorded without an engine only restates that. This is + * deliberately not `FluckEngine.isEngineHealthy()`, which is also false for an engine that is merely + * closed and will be recreated on next use. + */ +internal fun currentBrowserEngineHealth(): BrowserEngineHealth { + if (FluckEngine.resolveEngineDir(cacheHealthy = ChromiumAutoDownloader.isChromiumInstalledReadOnly()) == null) { + return BrowserEngineHealth.NotInstalled(FluckEngine.noUsableEngineReason()) + } + val initError = FluckEngine.initError + return when { + initError != null -> BrowserEngineHealth.Unavailable(initError.reason) + FluckEngine.isWedgeUnrecoverable -> BrowserEngineHealth.Unresponsive + else -> BrowserEngineHealth.Healthy + } +} + +internal fun currentMcpFaults(): McpFaults = + McpFaults( + killSwitch = McpToolRegistryImpl.killSwitchFault.value, + policy = McpToolRegistryImpl.policyFault.value, + ) + +private val EngineInitError.reason: String + get() = + when (this) { + is EngineInitError.LicenseValidation -> message + is EngineInitError.NetworkError -> message + is EngineInitError.Other -> message + } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt index 6bb82f1a5a..f52bfd88a2 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt @@ -608,6 +608,10 @@ object FluckEngine { wedgeUnrecoverable = unrecoverable } + /** Whether the wedge detector has spent its recycle budget; see [reportWedgeUnrecoverable]. */ + internal val isWedgeUnrecoverable: Boolean + get() = wedgeUnrecoverable + val engine: Engine get() = synchronized(engineLock) { diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt index 00a0cd7383..8099b4e2bf 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt @@ -40,7 +40,7 @@ object CliBootstrap { */ fun isHeadlessCli(args: Array): Boolean { val firstNonFlag = args.firstOrNull { !it.startsWith("-") }?.lowercase() - return firstNonFlag in setOf("status", "mcp", "completion") || + return firstNonFlag in setOf("status", "doctor", "mcp", "completion") || (args.isNotEmpty() && args.all { it in setOf("-h", "--help") }) } @@ -59,7 +59,7 @@ object CliBootstrap { CliDispatchResult.Exit(RisaLlmTokenCommand.execute()) } - // Headless CLI commands (status, mcp, completion, --help) target the running + // Headless CLI commands (status, doctor, mcp, completion, --help) target the running // instance or generate output headlessly. isHeadlessCli(args) -> { configureHeadlessLogging() diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt index aebe732718..e983cc75ea 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt @@ -37,7 +37,7 @@ internal object OsOpenArguments { * double-open, which is easy to miss and hard to attribute. */ internal val CLI_SUBCOMMANDS = - setOf("url", "workspace", "file", "folder", "terminal", "status", "mcp", "completion") + setOf("url", "workspace", "file", "folder", "terminal", "status", "doctor", "mcp", "completion") /** What a path on disk is, for deciding which deep link to build. */ internal enum class OpenTargetKind { FILE, DIRECTORY, ABSENT } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt index 0546ae27e3..02d83d7d5a 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt @@ -2,6 +2,8 @@ package ai.rever.boss.utils +import ai.rever.boss.health.WorkspaceHealthCollector +import ai.rever.boss.health.toJson import ai.rever.boss.plugin.api.McpToolResult import ai.rever.boss.plugin.pathutils.BossDirectories import ai.rever.boss.utils.logging.BossLogger @@ -696,8 +698,15 @@ private fun buildLlmTokenResponse(providerOverride: (() -> Result)?): St ) } +/** + * The STATUS response. [healthJson] is a parameter so a test can check that the real response carries + * the health report without starting a browser engine or reading the MCP files. + */ @Suppress("TooGenericExceptionCaught") -private fun buildStatusResponse(statusProviderOverride: (() -> String)?): String { +internal fun buildStatusResponse( + statusProviderOverride: (() -> String)?, + healthJson: () -> JsonObject = { WorkspaceHealthCollector().collect().toJson() }, +): String { val rawJson = if (statusProviderOverride != null) { statusProviderOverride.invoke() @@ -732,6 +741,8 @@ private fun buildStatusResponse(statusProviderOverride: (() -> String)?): String put("heapPercent", heapPercent) }, ) + // Never throws: a health source that cannot be read is reported as unchecked. + put("health", healthJson()) }.toString() } catch (e: Exception) { return RESPONSE_ERROR_PREFIX + (e.message ?: "Failed to query status") diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/BossDoctorCliTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/BossDoctorCliTest.kt new file mode 100644 index 0000000000..9cedeb3d8a --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/BossDoctorCliTest.kt @@ -0,0 +1,252 @@ +package ai.rever.boss.cli + +import ai.rever.boss.utils.SingleInstanceManager +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.core.parse +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BossDoctorCliTest { + private val runtimeDir = Files.createTempDirectory("boss-doctor-cli-test") + private val originalOut = System.out + private val originalErr = System.err + private val out = ByteArrayOutputStream() + private val err = ByteArrayOutputStream() + + @BeforeTest + fun setUp() { + SingleInstanceManager.runtimeDirOverride = runtimeDir.toFile() + SingleInstanceManager.statusProviderOverride = null + System.setOut(PrintStream(out)) + System.setErr(PrintStream(err)) + } + + @AfterTest + fun tearDown() { + System.setOut(originalOut) + System.setErr(originalErr) + SingleInstanceManager.statusProviderOverride = null + SingleInstanceManager.release() + SingleInstanceManager.runtimeDirOverride = null + runtimeDir.toFile().deleteRecursively() + } + + @Test + fun `doctor exits 0 and says so when no problems are reported`() { + serve(HEALTHY) + + assertEquals(0, exitOf("doctor")) + assertTrue(out.toString().contains("No problems found.")) + } + + @Test + fun `doctor exits 2 and lists each finding with its suggestion when degraded`() { + serve(DEGRADED) + + assertEquals(EXIT_DEGRADED, exitOf("doctor")) + val report = out.toString() + assertTrue(report.contains("[warning] Plugin 'Terminal Tab' was stopped after repeated failures."), report) + assertTrue(report.contains(" Suggested: Restart BOSS."), report) + assertTrue(report.contains("1 problem found."), report) + assertTrue(report.contains("Not checked: browser"), report) + } + + @Test + fun `a finding whose message contains line breaks is printed on one line`() { + serve(MULTILINE) + + assertEquals(EXIT_DEGRADED, exitOf("doctor")) + val report = out.toString() + assertTrue(report.contains("[critical] The file could not be read. JSON input: [1,2"), report) + assertFalse(report.lines().any { it.startsWith("JSON input") }, report) + } + + @Test + fun `doctor json prints only the health object and still exits 2 when degraded`() { + serve(DEGRADED) + + assertEquals(EXIT_DEGRADED, exitOf("doctor", "--json")) + val printed = Json.parseToJsonElement(out.toString().trim()).jsonObject + assertEquals(true, printed["degraded"]?.jsonPrimitive?.booleanOrNull) + assertFalse(printed.containsKey("version"), "doctor --json must print the health object, not the status") + } + + @Test + fun `doctor exits 1 on stderr when the running BOSS returns no health report`() { + serve(NO_HEALTH) + + assertEquals(1, exitOf("doctor")) + assertEquals("", out.toString()) + assertTrue(err.toString().contains("did not return a health report"), err.toString()) + } + + @Test + fun `doctor exits 1 when BOSS is not running`() { + assertEquals(1, exitOf("doctor")) + assertTrue(err.toString().contains("BOSS is not running"), err.toString()) + } + + @Test + fun `status keeps exit 0 when degraded and points at doctor`() { + serve(DEGRADED) + + assertEquals(0, exitOf("status")) + assertTrue(out.toString().contains("Health: 1 problem (run 'boss doctor')"), out.toString()) + } + + @Test + fun `doctor qualifies an incomplete report and respects degraded without details`() { + serve("""{"running":true,"health":{"degraded":false,"findings":[],"unchecked":["plugins"]}}""") + assertEquals(0, exitOf("doctor")) + assertTrue(out.toString().contains("No problems found in checked areas.")) + + SingleInstanceManager.statusProviderOverride = { + """{"running":true,"health":{"degraded":true,"findings":[],"unchecked":[]}}""" + } + assertEquals(EXIT_DEGRADED, exitOf("doctor")) + assertTrue(out.toString().contains("BOSS reports degraded health without finding details.")) + } + + @Test + fun `status does not claim OK when health sources could not be checked`() { + serve("""{"running":true,"health":{"degraded":false,"findings":[],"unchecked":["plugins","browser","mcp"]}}""") + + assertEquals(0, exitOf("status")) + val report = out.toString() + assertTrue(report.contains("No problems found in checked areas; not checked: plugins, browser, mcp"), report) + assertFalse(report.contains("Health: OK"), report) + } + + @Test + fun `status prints no health line for a BOSS that does not report health`() { + serve(NO_HEALTH) + + assertEquals(0, exitOf("status")) + assertFalse(out.toString().contains("Health:"), out.toString()) + } + + @Test + fun `doctor names the partly covered area and does not call it problem free`() { + serve(PARTIAL) + + assertEquals(EXIT_DEGRADED, exitOf("doctor")) + val report = out.toString() + assertTrue(report.contains("[warning] Plugin 'Terminal Tab' was stopped after repeated failures."), report) + assertTrue(report.contains("Partially checked: plugins."), report) + assertTrue(report.contains("so problems there may be missing"), report) + } + + @Test + fun `doctor does not claim no problems found when an area was only partly read`() { + serve("""{"running":true,"health":{"degraded":false,"findings":[],"unchecked":[],"partial":["plugins"]}}""") + + assertEquals(0, exitOf("doctor")) + val report = out.toString() + assertTrue(report.contains("No problems found in checked areas."), report) + assertFalse(report.contains("No problems found."), report) + assertTrue(report.contains("Partially checked: plugins."), report) + } + + @Test + fun `status says which area was only partly checked`() { + serve(PARTIAL) + + assertEquals(0, exitOf("status")) + val report = out.toString() + assertTrue(report.contains("1 problem (run 'boss doctor'); partially checked: plugins"), report) + } + + @Test + fun `status reports both unchecked and partly checked areas`() { + serve( + """{"running":true,"health":{"degraded":false,"findings":[],""" + + """"unchecked":["browser"],"partial":["plugins"]}}""", + ) + + assertEquals(0, exitOf("status")) + val report = out.toString() + assertTrue( + report.contains("No problems found in checked areas; not checked: browser; partially checked: plugins"), + report, + ) + } + + @Test + fun `a BOSS that does not send partial is still read as fully checked`() { + serve(HEALTHY) + + assertEquals(0, exitOf("doctor")) + assertTrue(out.toString().contains("No problems found.")) + assertFalse(out.toString().contains("Partially checked"), out.toString()) + + assertEquals(0, exitOf("status")) + assertTrue(out.toString().contains("Health: OK"), out.toString()) + } + + @Test + fun `doctor is registered as a subcommand`() { + assertTrue(createBossCLI().registeredSubcommands().any { it.commandName == "doctor" }) + } + + private fun serve(status: String) { + SingleInstanceManager.statusProviderOverride = { status } + assertTrue(SingleInstanceManager.acquireLock()) + // The in-process server logs to stdout while it starts. Waiting for it to answer puts that + // logging behind us, so exitOf can capture only the command's own output. + assertTrue(SingleInstanceManager.queryStatus().isSuccess) + } + + /** Run the CLI and return its exit code, with [out] and [err] holding only its own output. */ + private fun exitOf(vararg args: String): Int { + out.reset() + err.reset() + return try { + createBossCLI().parse(args.toList()) + 0 + } catch (e: ProgramResult) { + e.statusCode + } + } + + private companion object { + const val NO_HEALTH = """{"running":true,"version":"9.5.12"}""" + + const val HEALTHY = """{"running":true,"health":{"degraded":false,"findings":[],"unchecked":[]}}""" + + val DEGRADED = + """ + {"running":true,"version":"9.5.12","health":{"degraded":true,"unchecked":["browser"],"findings":[ + {"area":"plugins","severity":"warning","code":"plugin_stopped_after_failures", + "summary":"Plugin 'Terminal Tab' was stopped after repeated failures.", + "subject":"terminaltab","remedy":"Restart BOSS."}]}} + """.trimIndent() + + // One window reported a stopped plugin while another window's source failed. + val PARTIAL = + """ + {"running":true,"health":{"degraded":true,"unchecked":[],"partial":["plugins"],"findings":[ + {"area":"plugins","severity":"warning","code":"plugin_stopped_after_failures", + "summary":"Plugin 'Terminal Tab' was stopped after repeated failures.", + "subject":"terminaltab","remedy":"Restart BOSS."}]}} + """.trimIndent() + + // The summary carries a JSON-escaped line break, as a parser error message does. + val MULTILINE = + """ + {"running":true,"health":{"degraded":true,"unchecked":[],"findings":[ + {"area":"mcp","severity":"critical","code":"mcp_tools_withheld", + "summary":"The file could not be read.\nJSON input: [1,2"}]}} + """.trimIndent() + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/ChromiumAutoDownloaderTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/ChromiumAutoDownloaderTest.kt index c54abd728e..452601961c 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/ChromiumAutoDownloaderTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/config/ChromiumAutoDownloaderTest.kt @@ -116,6 +116,33 @@ class ChromiumAutoDownloaderTest { assertFalse(backup.exists()) } + @Test + fun `read-only cache inspection leaves the startup repair attempt available`() { + target.mkdirs() + File(target, "executable.name").writeText("BOSS") + File(target, "version.txt").writeText("9.2.0") + val plist = File(target, "BOSS.app/Contents/Info.plist") + plist.parentFile.mkdirs() + plist.writeText("CFBundleURLTypes") + val marker = File(root, "boss-chromium.types-repair") + + fun inspect(recordRepair: () -> Unit) = + ChromiumAutoDownloader.chromiumInstalledAt( + dir = target.toPath(), + requiredVersion = "9.2.0", + isMac = true, + repairAttempted = { marker.exists() }, + recordRepair = recordRepair, + ) + + assertFalse(inspect {}) + assertFalse(inspect {}) + assertFalse(marker.exists(), "health inspection must not consume the repair budget") + assertFalse(inspect { marker.writeText("9.2.0") }, "startup must still schedule the repair") + assertTrue(marker.exists()) + assertTrue(inspect { error("the one-shot repair must not repeat") }) + } + // ---- installFromCandidates: source fallback + checksum verification ---- private fun candidate( diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthCollectorTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthCollectorTest.kt new file mode 100644 index 0000000000..5af1c41fb2 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthCollectorTest.kt @@ -0,0 +1,97 @@ +package ai.rever.boss.health + +import ai.rever.boss.components.plugin.PluginHealthRow +import ai.rever.boss.components.plugin.PluginHealthSnapshot +import ai.rever.boss.components.plugin.PluginHealthStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WorkspaceHealthCollectorTest { + @Test + fun `a source that throws is reported as unchecked while the others are still read`() { + val report = + WorkspaceHealthCollector( + pluginSnapshots = { error("manager already disposed") }, + browserHealth = { BrowserEngineHealth.Unresponsive }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + assertEquals(setOf(HealthArea.PLUGINS), report.unchecked) + assertTrue(report.partial.isEmpty(), "nothing was read, so the area is unchecked rather than partial") + assertEquals(listOf(HealthCodes.BROWSER_ENGINE_UNRESPONSIVE), report.findings.map { it.code }) + } + + @Test + fun `a class missing from a health path is contained and never reported as healthy`() { + val report = + WorkspaceHealthCollector( + pluginSnapshots = { PluginSnapshotRead(listOf(ONE_WINDOW)) }, + browserHealth = { throw NoClassDefFoundError("com/teamdev/jxbrowser/engine/Engine") }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + assertEquals(setOf(HealthArea.BROWSER), report.unchecked) + assertFalse(report.degraded) + } + + @Test + fun `with no window open plugin health is unchecked rather than healthy`() { + val report = + WorkspaceHealthCollector( + pluginSnapshots = { PluginSnapshotRead(emptyList()) }, + browserHealth = { BrowserEngineHealth.Healthy }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + assertEquals(setOf(HealthArea.PLUGINS), report.unchecked) + assertFalse(report.degraded) + } + + @Test + fun `snapshots that were read survive a failing source and the area is reported partial`() { + val report = + WorkspaceHealthCollector( + pluginSnapshots = { PluginSnapshotRead(listOf(STOPPED_WINDOW), failedSources = 1) }, + browserHealth = { BrowserEngineHealth.Healthy }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + assertEquals(listOf(HealthCodes.PLUGIN_STOPPED), report.findings.map { it.code }) + assertTrue(report.unchecked.isEmpty()) + assertEquals(setOf(HealthArea.PLUGINS), report.partial) + } + + @Test + fun `plugins are unchecked rather than partial when every window source failed`() { + val report = + WorkspaceHealthCollector( + pluginSnapshots = { PluginSnapshotRead(emptyList(), failedSources = 2) }, + browserHealth = { BrowserEngineHealth.Healthy }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + assertEquals(setOf(HealthArea.PLUGINS), report.unchecked) + assertTrue(report.partial.isEmpty()) + assertFalse(report.degraded) + } + + private companion object { + val ONE_WINDOW = PluginHealthSnapshot(rows = emptyList(), sandboxDisabledPluginIds = emptySet()) + + val STOPPED_WINDOW = + PluginHealthSnapshot( + rows = + listOf( + PluginHealthRow( + "terminaltab", + "Terminal Tab", + PluginHealthStatus.NEEDS_ATTENTION, + "The plugin stopped and needs recovery.", + ), + ), + sandboxDisabledPluginIds = setOf("terminaltab"), + ) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthReportTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthReportTest.kt new file mode 100644 index 0000000000..d9b6de09dc --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthReportTest.kt @@ -0,0 +1,283 @@ +package ai.rever.boss.health + +import ai.rever.boss.components.plugin.PluginHealthRow +import ai.rever.boss.components.plugin.PluginHealthSnapshot +import ai.rever.boss.components.plugin.PluginHealthStatus +import ai.rever.boss.mcp.McpKillSwitchFault +import ai.rever.boss.mcp.McpPolicyFault +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WorkspaceHealthReportTest { + @Test + fun `a workspace with nothing wrong is not degraded`() { + val report = workspaceHealthReport(inputs(plugins = listOf(snapshot(row("notes", PluginHealthStatus.HEALTHY))))) + + assertFalse(report.degraded) + assertTrue(report.findings.isEmpty()) + assertTrue(report.unchecked.isEmpty()) + } + + @Test + fun `a plugin stopped by the sandbox watchdog is one warning naming the plugin`() { + val stoppedRow = + row("terminaltab", PluginHealthStatus.NEEDS_ATTENTION, name = "Terminal Tab", detail = STOPPED) + + val report = + workspaceHealthReport(inputs(plugins = listOf(snapshot(stoppedRow, stopped = setOf("terminaltab"))))) + + val finding = report.findings.single() + assertEquals(HealthCodes.PLUGIN_STOPPED, finding.code) + assertEquals(HealthArea.PLUGINS, finding.area) + assertEquals(HealthSeverity.WARNING, finding.severity) + assertEquals("terminaltab", finding.subject) + assertEquals("Plugin 'Terminal Tab' was stopped after repeated failures.", finding.summary) + assertTrue(report.degraded) + } + + @Test + fun `a plugin needing attention carries the health center's own detail`() { + val gated = + row( + "notes", + PluginHealthStatus.NEEDS_ATTENTION, + name = "Notes", + detail = "This plugin requires a newer BOSS version.", + ) + + val finding = workspaceHealthReport(inputs(plugins = listOf(snapshot(gated)))).findings.single() + + assertEquals(HealthCodes.PLUGIN_NEEDS_ATTENTION, finding.code) + assertEquals("Plugin 'Notes': This plugin requires a newer BOSS version.", finding.summary) + assertEquals("notes", finding.subject) + } + + @Test + fun `plugins unavailable by choice or by access are not degradations`() { + val disabled = row("notes", PluginHealthStatus.UNAVAILABLE, detail = "The plugin is disabled.") + val restricted = row("admin", PluginHealthStatus.UNAVAILABLE, detail = "Access is required for this plugin.") + + val report = workspaceHealthReport(inputs(plugins = listOf(snapshot(disabled, restricted)))) + + assertFalse(report.degraded) + } + + @Test + fun `the same problem in two windows is reported once`() { + val crashedRow = row("notes", PluginHealthStatus.NEEDS_ATTENTION, detail = CRASHED) + val stoppedRow = row("tasks", PluginHealthStatus.NEEDS_ATTENTION, detail = STOPPED) + + val report = + workspaceHealthReport( + inputs( + plugins = + listOf( + snapshot(crashedRow, stoppedRow, stopped = setOf("tasks")), + snapshot(crashedRow, stoppedRow, stopped = setOf("tasks")), + ), + ), + ) + + assertEquals( + listOf(HealthCodes.PLUGIN_NEEDS_ATTENTION to "notes", HealthCodes.PLUGIN_STOPPED to "tasks"), + report.findings.map { it.code to it.subject }.sortedBy { it.second }, + ) + } + + @Test + fun `a browser engine that is not installed yet is a warning that passes on the engine's advice`() { + val advice = "BOSS-branded Chromium not found. Please restart the app to trigger auto-download." + + val finding = + workspaceHealthReport(inputs(browser = BrowserEngineHealth.NotInstalled(advice))).findings.single() + + assertEquals(HealthCodes.BROWSER_ENGINE_NOT_INSTALLED, finding.code) + assertEquals(HealthSeverity.WARNING, finding.severity) + assertEquals(advice, finding.remedy) + } + + @Test + fun `a browser engine that could not start is critical and carries the engine's reason`() { + val reason = "License validation failed. Please check your internet connection." + + val finding = + workspaceHealthReport(inputs(browser = BrowserEngineHealth.Unavailable(reason))).findings.single() + + assertEquals(HealthCodes.BROWSER_ENGINE_UNAVAILABLE, finding.code) + assertEquals(HealthSeverity.CRITICAL, finding.severity) + assertTrue(finding.summary.endsWith(reason), finding.summary) + assertNull(finding.subject) + } + + @Test + fun `a browser engine whose automatic recovery gave up is critical`() { + val finding = workspaceHealthReport(inputs(browser = BrowserEngineHealth.Unresponsive)).findings.single() + + assertEquals(HealthCodes.BROWSER_ENGINE_UNRESPONSIVE, finding.code) + assertEquals(HealthSeverity.CRITICAL, finding.severity) + } + + @Test + fun `an unreadable kill-switch file withholds every tool and is critical`() { + val fault = + McpKillSwitchFault.PersistedSetUnreadable("/boss/mcp-disabled-tools.json", null, 3, "unexpected end") + + val finding = mcpFinding(killSwitch = fault) + + assertEquals(HealthCodes.MCP_TOOLS_WITHHELD, finding.code) + assertEquals(HealthSeverity.CRITICAL, finding.severity) + assertEquals(fault.message, finding.summary) + } + + @Test + fun `a kill-switch toggle that could not be saved is a warning about that tool`() { + val fault = + McpKillSwitchFault.TogglePersistFailed( + toolName = "run_command", + enabled = true, + outcome = McpKillSwitchFault.ToggleOutcome.REFUSED, + error = "disk full", + ) + + val finding = mcpFinding(killSwitch = fault) + + assertEquals(HealthCodes.MCP_TOOL_SETTING_NOT_SAVED, finding.code) + assertEquals(HealthSeverity.WARNING, finding.severity) + assertEquals("run_command", finding.subject) + assertEquals(fault.message, finding.summary) + } + + @Test + fun `policy faults are critical only when the policy file cannot be read`() { + val unreadable = McpPolicyFault.PersistedPolicyUnreadable("/boss/mcp-tool-policy.json", "unexpected end") + val notSaved = McpPolicyFault.PolicyPersistFailed("run_command", "disk full") + + val unreadableFinding = mcpFinding(policy = unreadable) + val notSavedFinding = mcpFinding(policy = notSaved) + + assertEquals(HealthCodes.MCP_POLICY_UNREADABLE, unreadableFinding.code) + assertEquals(HealthSeverity.CRITICAL, unreadableFinding.severity) + assertEquals(HealthCodes.MCP_POLICY_NOT_SAVED, notSavedFinding.code) + assertEquals(HealthSeverity.WARNING, notSavedFinding.severity) + assertEquals("run_command", notSavedFinding.subject) + } + + @Test + fun `sources that could not be read are unchecked, never healthy`() { + val report = workspaceHealthReport(WorkspaceHealthInputs(plugins = null, browser = null, mcp = null)) + + assertEquals(HealthArea.entries.toSet(), report.unchecked) + assertFalse(report.degraded) + } + + @Test + fun `critical findings are listed before warnings`() { + val stoppedRow = row("tasks", PluginHealthStatus.NEEDS_ATTENTION, detail = STOPPED) + + val report = + workspaceHealthReport( + inputs( + plugins = listOf(snapshot(stoppedRow, stopped = setOf("tasks"))), + browser = BrowserEngineHealth.Unresponsive, + ), + ) + + assertEquals(listOf(HealthSeverity.CRITICAL, HealthSeverity.WARNING), report.findings.map { it.severity }) + } + + @Test + fun `a failed window source marks plugins partial while keeping what the others reported`() { + val stoppedRow = row("tasks", PluginHealthStatus.NEEDS_ATTENTION, detail = STOPPED) + + val report = + workspaceHealthReport( + inputs(plugins = listOf(snapshot(stoppedRow, stopped = setOf("tasks"))), pluginSourceFailures = 1), + ) + + assertEquals(listOf(HealthCodes.PLUGIN_STOPPED), report.findings.map { it.code }) + assertEquals(setOf(HealthArea.PLUGINS), report.partial) + assertTrue(report.unchecked.isEmpty()) + } + + @Test + fun `an area nothing could be read from is unchecked and never also partial`() { + val report = workspaceHealthReport(inputs(plugins = null, pluginSourceFailures = 2)) + + assertEquals(setOf(HealthArea.PLUGINS), report.unchecked) + assertTrue(report.partial.isEmpty()) + } + + @Test + fun `a fully read area is neither unchecked nor partial`() { + val report = workspaceHealthReport(inputs(plugins = listOf(snapshot(row("notes", PluginHealthStatus.HEALTHY))))) + + assertTrue(report.unchecked.isEmpty()) + assertTrue(report.partial.isEmpty()) + } + + @Test + fun `json uses lowercase wire names and omits absent fields`() { + val report = + WorkspaceHealthReport( + findings = + listOf(HealthFinding(HealthArea.MCP, HealthSeverity.CRITICAL, "mcp_tools_withheld", "Withheld.")), + unchecked = setOf(HealthArea.BROWSER), + ) + + val json = report.toJson() + + assertTrue(json.getValue("degraded").jsonPrimitive.boolean) + val finding = (json.getValue("findings") as JsonArray).single().jsonObject + assertEquals("mcp", finding.getValue("area").jsonPrimitive.content) + assertEquals("critical", finding.getValue("severity").jsonPrimitive.content) + assertEquals("mcp_tools_withheld", finding.getValue("code").jsonPrimitive.content) + assertFalse(finding.containsKey("subject")) + assertFalse(finding.containsKey("remedy")) + assertEquals(listOf("browser"), (json.getValue("unchecked") as JsonArray).map { it.jsonPrimitive.content }) + assertTrue((json.getValue("partial") as JsonArray).isEmpty()) + } + + @Test + fun `json carries partial areas by wire name`() { + val json = WorkspaceHealthReport(findings = emptyList(), partial = setOf(HealthArea.PLUGINS)).toJson() + + assertEquals(listOf("plugins"), (json.getValue("partial") as JsonArray).map { it.jsonPrimitive.content }) + assertFalse(json.getValue("degraded").jsonPrimitive.boolean) + } + + private fun inputs( + plugins: List? = emptyList(), + browser: BrowserEngineHealth? = BrowserEngineHealth.Healthy, + mcp: McpFaults? = McpFaults(killSwitch = null, policy = null), + pluginSourceFailures: Int = 0, + ) = WorkspaceHealthInputs(plugins, browser, mcp, pluginSourceFailures) + + private fun mcpFinding( + killSwitch: McpKillSwitchFault? = null, + policy: McpPolicyFault? = null, + ): HealthFinding = workspaceHealthReport(inputs(mcp = McpFaults(killSwitch, policy))).findings.single() + + private fun snapshot( + vararg rows: PluginHealthRow, + stopped: Set = emptySet(), + ) = PluginHealthSnapshot(rows.toList(), stopped) + + private fun row( + id: String, + status: PluginHealthStatus, + name: String = id, + detail: String = "Running normally.", + ) = PluginHealthRow(id, name, status, detail) + + private companion object { + const val STOPPED = "The plugin stopped and needs recovery." + const val CRASHED = "The plugin needs recovery after an unexpected failure." + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthSourcesTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthSourcesTest.kt new file mode 100644 index 0000000000..b9b2a13ffe --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/health/WorkspaceHealthSourcesTest.kt @@ -0,0 +1,94 @@ +package ai.rever.boss.health + +import ai.rever.boss.components.plugin.PluginHealthRow +import ai.rever.boss.components.plugin.PluginHealthSnapshot +import ai.rever.boss.components.plugin.PluginHealthStatus +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WorkspaceHealthSourcesTest { + @BeforeTest + fun setUp() { + WorkspaceHealthSources.clear() + } + + @AfterTest + fun tearDown() { + WorkspaceHealthSources.clear() + } + + @Test + fun `every registered window contributes a snapshot`() { + WorkspaceHealthSources.registerPlugins("window-a") { snapshotOf("notes") } + WorkspaceHealthSources.registerPlugins("window-b") { snapshotOf("tasks") } + + assertEquals(setOf("notes", "tasks"), pluginIds()) + } + + @Test + fun `unregistering a replaced source keeps the registration that replaced it`() { + val first: () -> PluginHealthSnapshot = { snapshotOf("first") } + val second: () -> PluginHealthSnapshot = { snapshotOf("second") } + WorkspaceHealthSources.registerPlugins("window", first) + WorkspaceHealthSources.registerPlugins("window", second) + + WorkspaceHealthSources.unregisterPlugins("window", first) + assertEquals(setOf("second"), pluginIds()) + + WorkspaceHealthSources.unregisterPlugins("window", second) + assertTrue(WorkspaceHealthSources.pluginSnapshots().snapshots.isEmpty()) + } + + @Test + fun `a window source that throws is counted while every other window is still read`() { + WorkspaceHealthSources.registerPlugins("window-a") { snapshotOf("notes") } + WorkspaceHealthSources.registerPlugins("window-b") { error("manager already disposed") } + WorkspaceHealthSources.registerPlugins("window-c") { throw NoClassDefFoundError("ai/rever/boss/Plugin") } + + val read = WorkspaceHealthSources.pluginSnapshots() + + assertEquals(setOf("notes"), read.snapshots.flatMap { s -> s.rows.map { it.pluginId } }.toSet()) + assertEquals(2, read.failedSources) + } + + @Test + fun `a failing window source does not hide a stopped plugin in another window`() { + WorkspaceHealthSources.registerPlugins("window-a") { snapshotOf("terminaltab", stopped = true) } + WorkspaceHealthSources.registerPlugins("window-b") { error("manager already disposed") } + + val report = + WorkspaceHealthCollector( + pluginSnapshots = WorkspaceHealthSources::pluginSnapshots, + browserHealth = { BrowserEngineHealth.Healthy }, + mcpFaults = { McpFaults(killSwitch = null, policy = null) }, + ).collect() + + val finding = report.findings.single() + assertEquals(HealthCodes.PLUGIN_STOPPED, finding.code) + assertEquals("terminaltab", finding.subject) + assertTrue(report.unchecked.isEmpty(), "the window that answered was read, so plugins is not unchecked") + assertEquals(setOf(HealthArea.PLUGINS), report.partial, "partial coverage must be declared, not hidden") + } + + private fun pluginIds(): Set = + WorkspaceHealthSources + .pluginSnapshots() + .snapshots + .flatMap { snapshot -> snapshot.rows.map { it.pluginId } } + .toSet() + + private fun snapshotOf( + pluginId: String, + stopped: Boolean = false, + ): PluginHealthSnapshot { + val status = if (stopped) PluginHealthStatus.NEEDS_ATTENTION else PluginHealthStatus.HEALTHY + val detail = if (stopped) "The plugin stopped and needs recovery." else "Running normally." + return PluginHealthSnapshot( + rows = listOf(PluginHealthRow(pluginId, pluginId, status, detail)), + sandboxDisabledPluginIds = if (stopped) setOf(pluginId) else emptySet(), + ) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/CliBootstrapTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/CliBootstrapTest.kt index bf77e11cd2..5ebb3cb729 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/CliBootstrapTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/startup/CliBootstrapTest.kt @@ -10,6 +10,7 @@ class CliBootstrapTest { fun isHeadlessCliRecognizesStandardCommands() { assertTrue(CliBootstrap.isHeadlessCli(arrayOf("status"))) assertTrue(CliBootstrap.isHeadlessCli(arrayOf("STATUS"))) + assertTrue(CliBootstrap.isHeadlessCli(arrayOf("doctor"))) assertTrue(CliBootstrap.isHeadlessCli(arrayOf("mcp"))) assertTrue(CliBootstrap.isHeadlessCli(arrayOf("completion"))) assertTrue(CliBootstrap.isHeadlessCli(arrayOf("--help"))) diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/OsOpenArgumentsTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/OsOpenArgumentsTest.kt index 1d72ffc397..ffb3469d6a 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/OsOpenArgumentsTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/OsOpenArgumentsTest.kt @@ -1,10 +1,10 @@ package ai.rever.boss.utils +import ai.rever.boss.cli.createBossCLI import java.io.File import java.net.URLDecoder import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull import kotlin.test.assertTrue /** @@ -224,27 +224,11 @@ class OsOpenArgumentsTest { @Test fun `the CLI subcommand list matches the CLI`() { // The failure mode when these drift is a double-open, which is easy to - // miss and hard to attribute - so it is pinned against the source that - // registers them. - val root = - assertNotNull( - generateSequence(File("").absoluteFile) { it.parentFile } - .firstOrNull { File(it, "composeApp/build.gradle.kts").isFile }, - "could not locate the repository root", - ) - val source = File(root, "composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt") - assertTrue(source.isFile, "BossCommand.kt not found at ${source.absolutePath}") - - // The negative lookbehind excludes the root command, which is declared - // as `NoOpCliktCommand(name = "boss")` and whose text contains - // `CliktCommand(name = "boss")` as a substring. Without it the test - // demands a "boss" subcommand that does not exist. - val registered = - Regex("""(?` | Loads a workspace configuration | `boss workspace ./workspace.json` | | `boss terminal` | Opens a new integrated BossTerm pane | `boss terminal` | | `boss status` | Checks running BossConsole health and status | `boss status --json` | +| `boss doctor` | Reports health problems with suggested next steps (exit `2` when degraded) | `boss doctor --json` | | `boss mcp ` | Discovers and invokes MCP tools | `boss mcp list` | | `boss completion ` | Generates shell tab-completion scripts | `boss completion bash > ~/.boss-complete.sh` | @@ -63,6 +64,47 @@ boss status --json } ``` +#### Health: the `health` field and `boss doctor` + +`boss status --json` also carries a `health` object. It reports problems BOSS already tracks but previously showed only inside a window: plugins the sandbox watchdog stopped after repeated failures, plugins that need attention in **Help > Plugin Health & Recovery**, a browser engine that is not installed, could not start, or has stopped responding, and MCP kill-switch or policy faults that withhold tools. The field is additive and `boss status` keeps its existing exit codes; its human output gains a single `Health:` line. + +`boss doctor` prints the same report with a suggested next step for each problem, and exits with code `2` while any problem is present. Both commands are read-only: they never change plugin, browser or MCP state. Both also say in words when coverage was incomplete: `Not checked:` for an area nothing could be read from, and `Partially checked:` for one where some sources answered and others failed. + +```bash +# Human-readable report: exit 0 when healthy, 2 when degraded, 1 when BOSS is not running +boss doctor + +# The health object as JSON, with the same exit codes +boss doctor --json +``` + +**Health Object Example**: +```json +{ + "degraded": true, + "findings": [ + { + "area": "plugins", + "severity": "warning", + "code": "plugin_stopped_after_failures", + "summary": "Plugin 'Terminal Tab' was stopped after repeated failures.", + "subject": "ai.rever.boss.plugin.dynamic.terminaltab", + "remedy": "Reload it from Help > Plugin Health & Recovery in the affected window, or restart BOSS." + } + ], + "unchecked": [], + "partial": [] +} +``` + +- `area` is `plugins`, `browser` or `mcp`. +- `severity` is `critical` when something that should work is broken and a whole capability is gone (a browser engine that failed, or every MCP tool), and `warning` for something narrower: one plugin, one setting that was not saved, or a browser engine that has not been downloaded yet. Critical findings are listed first. +- `code` is stable for scripts to match on: `plugin_stopped_after_failures`, `plugin_needs_attention`, `browser_engine_not_installed`, `browser_engine_unavailable`, `browser_engine_unresponsive`, `mcp_tools_withheld`, `mcp_tool_setting_not_saved`, `mcp_policy_unreadable`, `mcp_policy_not_saved`. `summary` is for people and may be reworded. +- `subject` (a plugin id or tool name) and `remedy` are omitted when they do not apply. +- `unchecked` lists areas whose state could not be read at all, including `plugins` while no BOSS window is open. They are neither healthy nor degraded and do not affect the exit code. +- `partial` lists areas that were read from several sources where some of those sources failed. Today only `plugins` can appear, because it has one source per open window: if one window's source fails, the other windows' findings are still reported and `plugins` is listed here instead of being dropped into `unchecked`. Findings in a partial area are real, but the area is not fully covered, so an empty result there is not a clean bill of health. Like `unchecked`, it does not affect the exit code. An area is never in both `unchecked` and `partial`. The field is additive: a BOSS that predates it simply omits it, and both `boss status` and `boss doctor` treat an absent `partial` as empty. +- Plugin health covers every open window that could be read. A plugin you disabled, or one your role cannot access, is not reported as a problem. + --- ### 2. `boss mcp list` @@ -159,6 +201,7 @@ The CLI adheres to strict UNIX process exit codes and standard stream separation - **Exit Code `0`**: Operation succeeded. `stdout` contains the tool output or JSON response. - **Exit Code `1`**: Tool execution failed (`isError == true`), invalid tool arguments, or desktop app offline. Clikt usage errors also use exit code `1`. The error description is written strictly to `stderr`, leaving `stdout` clean so shell pipelines do not ingest corrupted data. +- **Exit Code `2`**: `boss doctor` only. BOSS is running but reported at least one health finding. `stdout` still contains the report, so a script can branch on the code and read the details. `boss status` never uses this code. ### Offline Fail-Fast If BossConsole is not running, commands fail immediately without hanging: diff --git a/docs/mcp-agentless-operators.md b/docs/mcp-agentless-operators.md index 2e7c4d6c80..1a787aa9a9 100644 --- a/docs/mcp-agentless-operators.md +++ b/docs/mcp-agentless-operators.md @@ -19,6 +19,11 @@ shared across windows, while Toolbox opens only in the window where you selected Persisted disables live in the app data file `mcp-disabled-tools.json`. Fail-closed faults surface in the bottom status bar (`McpKillSwitchFault`). +These faults, and MCP policy faults, are also reported without a GUI. `boss doctor` prints each +one with a suggested next step and exits with code `2` while any is present, and +`boss status --json` carries the same data in its `health` field. Neither command changes +anything. See [CLI.md](CLI.md#health-the-health-field-and-boss-doctor). + ## Minimum attach path (for agents) 1. Sign in to BOSS. diff --git a/scripts/boss b/scripts/boss index 8f37e62bef..8bccfe4080 100755 --- a/scripts/boss +++ b/scripts/boss @@ -186,7 +186,7 @@ case "$1" in open_url "$deep_link" ;; - status|mcp|completion) + status|doctor|mcp|completion) BOSS_BIN="${BOSS_BIN:-}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" if [ -z "$BOSS_BIN" ]; then @@ -234,6 +234,7 @@ Usage: Commands: status Queries status and health of the running BOSS instance + doctor Reports problems in the running BOSS instance (exit 2 when degraded) mcp [args] Discovers and invokes desktop MCP tools (list, describe, invoke) completion Generates shell completion script (bash, zsh, fish) url Opens a URL in Fluck browser diff --git a/scripts/boss.bat b/scripts/boss.bat index 770ea828fc..4779cffe7a 100644 --- a/scripts/boss.bat +++ b/scripts/boss.bat @@ -26,6 +26,7 @@ REM Parse command set "COMMAND=%~1" if /i "%COMMAND%"=="status" goto :cmd_forward_exe +if /i "%COMMAND%"=="doctor" goto :cmd_forward_exe if /i "%COMMAND%"=="mcp" goto :cmd_forward_exe if /i "%COMMAND%"=="completion" goto :cmd_forward_exe if /i "%COMMAND%"=="url" goto :cmd_url @@ -145,6 +146,7 @@ echo boss ^ [arguments] Run explicit command echo. echo Commands: echo status Queries status and health of the running BOSS instance +echo doctor Reports problems in the running BOSS instance (exit 2 when degraded) echo mcp ^ [args] Discovers and invokes desktop MCP tools (list, describe, invoke) echo completion ^ Generates shell completion script (bash, zsh, fish) echo url ^ Opens a URL in Fluck browser diff --git a/scripts/boss.ps1 b/scripts/boss.ps1 index 75de3b96c2..01288737a2 100644 --- a/scripts/boss.ps1 +++ b/scripts/boss.ps1 @@ -128,6 +128,7 @@ function Show-Help { Write-Host "" Write-Host "Commands:" Write-Host " status Queries status and health of the running BOSS instance" + Write-Host " doctor Reports problems in the running BOSS instance (exit 2 when degraded)" Write-Host " mcp [args] Discovers and invokes desktop MCP tools (list, describe, invoke)" Write-Host " completion Generates shell completion script (bash, zsh, fish)" Write-Host " url Opens a URL in Fluck browser" @@ -237,7 +238,7 @@ switch ($Command.ToLower()) { Open-BossDeepLink $deepLink } - { $_ -in "status", "mcp", "completion" } { + { $_ -in "status", "doctor", "mcp", "completion" } { $bossExe = $env:BOSS_EXE if ($bossExe -and -not (Test-Path $bossExe -PathType Leaf)) { [Console]::Error.WriteLine("Error: BOSS_EXE does not name an executable file.") From aa03486d7f04fcb1ed1d15759ee111ba11650007 Mon Sep 17 00:00:00 2001 From: Vidhaankhare16 <142911723+Vidhaankhare16@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:09:49 +0530 Subject: [PATCH 42/49] fix(cli): write piped CLI output as UTF-8 (#604) Co-authored-by: Shivang --- .../rever/boss/cli/HeadlessOutputEncoding.kt | 38 ++++++ .../ai/rever/boss/startup/CliBootstrap.kt | 2 + .../boss/cli/HeadlessOutputEncodingTest.kt | 117 ++++++++++++++++++ docs/CLI.md | 1 + 4 files changed, 158 insertions(+) create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/HeadlessOutputEncoding.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/HeadlessOutputEncodingTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/HeadlessOutputEncoding.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/HeadlessOutputEncoding.kt new file mode 100644 index 0000000000..93cf1b4b4a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/HeadlessOutputEncoding.kt @@ -0,0 +1,38 @@ +package ai.rever.boss.cli + +import java.io.FileDescriptor +import java.io.FileOutputStream +import java.io.OutputStream +import java.io.PrintStream +import java.nio.charset.StandardCharsets + +/** + * Makes headless CLI output UTF-8 wherever it is not a Windows console. + * + * JDK 17, the runtime BOSS is built and shipped with, encodes `System.out` and `System.err` with the + * platform default charset unless the stream is a Windows console. On Windows that default is the ANSI + * code page, so output read by an agent, a pipe or a file loses every character outside it: an arrow is + * written as `?`, and an accented letter as a byte that is not valid UTF-8. That covers `boss mcp invoke` + * tool output and every `--json` response, and no reader can repair a `?` afterwards. + * + * A Windows console is left alone. The JDK already encodes for the console's code page there, and UTF-8 + * bytes would be drawn as the wrong characters. The JDK marks those streams by setting + * `sun.stdout.encoding` and `sun.stderr.encoding`, which it does for a console and nothing else. + * + * The replacement streams have no buffer below the encoder, so nothing is left unwritten when the CLI + * calls `exitProcess`. + */ +internal fun configureHeadlessOutputEncoding( + stdoutConsoleEncoding: String? = System.getProperty("sun.stdout.encoding"), + stderrConsoleEncoding: String? = System.getProperty("sun.stderr.encoding"), + stdout: () -> OutputStream = { FileOutputStream(FileDescriptor.out) }, + stderr: () -> OutputStream = { FileOutputStream(FileDescriptor.err) }, +) { + if (writesUtf8(stdoutConsoleEncoding)) System.setOut(utf8PrintStream(stdout())) + if (writesUtf8(stderrConsoleEncoding)) System.setErr(utf8PrintStream(stderr())) +} + +/** Whether a standard stream gets UTF-8: only when the JVM did not mark it as a console. */ +internal fun writesUtf8(consoleEncoding: String?): Boolean = consoleEncoding.isNullOrBlank() + +internal fun utf8PrintStream(out: OutputStream): PrintStream = PrintStream(out, true, StandardCharsets.UTF_8) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt index 8099b4e2bf..7aa6916707 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt @@ -1,6 +1,7 @@ package ai.rever.boss.startup import ai.rever.boss.cli.configureHeadlessLogging +import ai.rever.boss.cli.configureHeadlessOutputEncoding import ai.rever.boss.cli.createBossCLI import ai.rever.boss.llm.RisaLlmTokenCommand import ai.rever.boss.utils.DeepLinkHandler @@ -62,6 +63,7 @@ object CliBootstrap { // Headless CLI commands (status, doctor, mcp, completion, --help) target the running // instance or generate output headlessly. isHeadlessCli(args) -> { + configureHeadlessOutputEncoding() configureHeadlessLogging() try { createBossCLI().main(args) diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/HeadlessOutputEncodingTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/HeadlessOutputEncodingTest.kt new file mode 100644 index 0000000000..01382f5190 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/HeadlessOutputEncodingTest.kt @@ -0,0 +1,117 @@ +package ai.rever.boss.cli + +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.URLClassLoader +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * RIGHTWARDS ARROW, which neither Cp1252 nor US-ASCII can encode. Built from its code point to keep + * this file ASCII. + */ +private val ARROW = Char(0x2192).toString() + +/** An arrow, and LATIN SMALL LETTER E WITH ACUTE, which Cp1252 and UTF-8 encode differently. */ +internal val ENCODING_PROBE_TEXT = "arrow $ARROW e-acute ${Char(0xE9)}" + +class HeadlessOutputEncodingTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `only a stream the JVM did not mark as a console is switched to UTF-8`() { + assertTrue(writesUtf8(null)) + assertTrue(writesUtf8("")) + assertFalse(writesUtf8("Cp437")) + assertFalse(writesUtf8("UTF-8")) + } + + @Test + fun `a redirected stream is replaced with UTF-8 and a console stream is left as it is`() { + val originalOut = System.out + val originalErr = System.err + val captured = ByteArrayOutputStream() + try { + configureHeadlessOutputEncoding( + stdoutConsoleEncoding = null, + stderrConsoleEncoding = "Cp437", + stdout = { captured }, + stderr = { error("a console stream must not be replaced") }, + ) + System.out.print(ARROW) + + assertContentEquals(ARROW.toByteArray(StandardCharsets.UTF_8), captured.toByteArray()) + assertSame(originalErr, System.err) + } finally { + System.setOut(originalOut) + System.setErr(originalErr) + } + } + + @Test + fun `piped CLI output is UTF-8 even when the JVM default charset cannot encode it`() { + val output = runProbe(configure = true) + + assertEquals(ENCODING_PROBE_TEXT, String(output, StandardCharsets.UTF_8).trim()) + } + + @Test + fun `without the fix the same piped output loses the text, so the test above can fail`() { + val output = runProbe(configure = false) + + assertFalse(String(output, StandardCharsets.UTF_8).contains(ENCODING_PROBE_TEXT)) + assertTrue(String(output, StandardCharsets.US_ASCII).contains("arrow ? e-acute ?")) + } + + /** + * Runs [HeadlessOutputEncodingProbe] in a child JVM whose stdout is a pipe and whose default charset is + * US-ASCII, so the result does not depend on the platform running the test. Launched the way + * [HeadlessLoggingTest] launches its probe, for the same Windows command-line limit. + */ + private fun runProbe(configure: Boolean): ByteArray { + val urls = + generateSequence(javaClass.classLoader) { it.parent } + .filterIsInstance() + .flatMap { it.getURLs().asSequence() } + .map { File(it.toURI()).path } + .toList() + val classpath = + (urls + System.getProperty("java.class.path").split(File.pathSeparator)) + .distinct() + .joinToString(File.pathSeparator) + val java = File(System.getProperty("java.home"), "bin/java").absolutePath + val argumentsFile = tempDir.resolve("encoding probe.args") + val quotedClasspath = classpath.replace("\\", "\\\\").replace("\"", "\\\"") + val mainClass = HeadlessOutputEncodingProbe::class.java.name + val mode = if (configure) "configure" else "control" + Files.writeString(argumentsFile, "-Dfile.encoding=US-ASCII\n-cp\n\"$quotedClasspath\"\n$mainClass\n$mode\n") + val process = ProcessBuilder(java, "@${argumentsFile.toAbsolutePath()}").start() + try { + val output = process.inputStream.readBytes() + assertTrue(process.waitFor(20, TimeUnit.SECONDS), "encoding probe must finish") + assertEquals(0, process.exitValue(), String(process.errorStream.readBytes())) + return output + } finally { + if (process.isAlive) process.destroyForcibly() + } + } +} + +/** Prints non-ASCII text the way a headless CLI command would; never starts the application. */ +object HeadlessOutputEncodingProbe { + @JvmStatic + fun main(args: Array) { + if (args.firstOrNull() == "configure") configureHeadlessOutputEncoding() + println(ENCODING_PROBE_TEXT) + } +} diff --git a/docs/CLI.md b/docs/CLI.md index 7d092a7fe7..feb4340821 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -201,6 +201,7 @@ The CLI adheres to strict UNIX process exit codes and standard stream separation - **Exit Code `0`**: Operation succeeded. `stdout` contains the tool output or JSON response. - **Exit Code `1`**: Tool execution failed (`isError == true`), invalid tool arguments, or desktop app offline. Clikt usage errors also use exit code `1`. The error description is written strictly to `stderr`, leaving `stdout` clean so shell pipelines do not ingest corrupted data. +- **Output encoding**: Piped or redirected output, including every `--json` response and `boss mcp invoke` tool output, is UTF-8 on every platform; a Windows console keeps its own code page. Windows PowerShell 5.1 decodes a native command's output with `[Console]::OutputEncoding`, so set it to UTF-8 (`[Console]::OutputEncoding = [System.Text.Encoding]::UTF8`) before capturing output that contains non-ASCII text. - **Exit Code `2`**: `boss doctor` only. BOSS is running but reported at least one health finding. `stdout` still contains the report, so a script can branch on the code and read the details. `boss status` never uses this code. ### Offline Fail-Fast From 0e3a7d193e48dc07435eca1bbc69c14a9ebb1c2e Mon Sep 17 00:00:00 2001 From: Kanak Khandelwal <152528927+Kanakk23@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:12:33 +0530 Subject: [PATCH 43/49] feat(cli): add plugin init, validate, and link commands with dev hot-reload (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Do not fail legacy bindings that the matcher now resolves * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Reject empty shortcut names without rejecting the space alias * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Keep invalid-key diagnostic outside the tester object function limit * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Apply tester formatting and wrap the invalid-key diagnostic * Preserve malformed native codes instead of storing invalid Unicode * Use required multiline when-branch formatting * Cover the new dev chrome-density shortcut in capture round trips * Fix API guard false positives for private lambda accessors --------- Co-authored-by: Claude Opus 5 Co-authored-by: AdityaK-iiita Co-authored-by: Antriksh1984 Co-authored-by: Shivang * docs: the keymap's two matchers, and the key names a hand-edited file is read against (#377) * docs: the keymap's two matchers, and the key names a hand-edited file is read against KEYBOARD_SHORTCUTS.md documents keymap-settings.json as hand-editable and then says nothing about what a "key" value may contain. It also describes an event flow that leaves out the component doing most of the dispatching. Three gaps, all of them things a reader currently has to find in the source: - `AWTKeyboardInterceptor` appeared once in the whole document, in a late section about plugin surfaces, and not in the event flow or the component list. It is the KeyEventDispatcher on the KeyboardFocusManager, which is what makes shortcuts work at all while a terminal or a browser holds focus - BossTerm consumes every key it is given, and the browser surface is heavyweight. - There are two matchers reading one keymap from two different name sources, and the document described neither the second one nor the consequence. A chord that works normally but reports no match in the Shortcuts tester is that disagreement, and there was nothing to read that would say so. - No list of accepted key names existed, so anyone hand-editing the file was guessing. "Left", "DirectionLeft", "ArrowLeft" and the arrow glyph are one key, and so are "-" and "Minus". The Key.toString() table is measured rather than described: the same key renders a word while the AWT toolkit is cold and a glyph once it is up, which is the detail that makes a hand-written comparison against a key name unsafe. Troubleshooting gains the two questions the document could not previously answer: a shortcut you rebound yourself doing nothing, and a shortcut that works in one place but not another. No behaviour change; documentation only. Co-Authored-By: Claude Opus 5 * Clarify static shortcut testing and legacy key repair documentation * Address review of version-dependent shortcut troubleshooting * Fix API guard false positives for private lambda accessors --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * feat(cli): add headless CLI agent MCP harness and IPC bridge (#349) * feat(cli): headless CLI agent MCP harness and IPC bridge - Add boss status and boss mcp (list, describe, invoke) subcommands - Add single-line Base64 wire protocol over authenticated loopback IPC - Add fast-path CLI execution before GUI bootstrap (<100ms) - Suppress dev-mode realtime websocket reconnect storm - Add 65 passing unit tests and comprehensive docs in docs/CLI.md * style(cli): resolve ktlint formatting and detekt static analysis violations * fix(cli): preserve MCP denials and harden headless IPC framing * fix(cli): isolate headless diagnostics and plugin schema failures * test(cli): avoid Windows subprocess command-line limit * fix(test): exclude private synthetic accessors from API contract --------- Co-authored-by: Shivang * feat(mcp): add runtime safety and human approval for MCP actions (#336) * feat(mcp): add Agent Tool Sandbox with human approval * fix(mcp): close shell approval bypass and serialize prompt lifecycle * fix(mcp): cover infrastructure mutations and preserve approval focus --------- Co-authored-by: Shivang * fix(plugins): bound signature mismatch retries and protect sidecar files (#401) * Make the sidecar backfill actually run, and bound what it costs when it can't The backfill added in #101 for already-installed system plugins usually did nothing, silently, and where it could never succeed it retried forever. Both halves of BossConsole#108. It usually did nothing, for two independent reasons. The drain was one-shot, triggered by the first non-null token from the sessionStatus collector in initialize(). The only path that enqueues runs from ensureSystemPluginsInstalled, which at startup is reached lazily on first DynamicPluginManager access, behind copyBundledPluginsToPluginDir and a JAR scan. A returning user with a session restored from local storage can emit Authenticated first, so the drain polled an empty queue, burned a process-global flag, and nothing was ever backfilled. Nothing logged either, so it looked exactly like success. Enqueues from the realtime manifest path arrived after the only trigger had already fired and could never be drained at all. And queued items were dropped rather than deferred. stillNeedsSidecar skipped any plugin with an update check in flight, but scheduleBackgroundUpdateCheck raises that flag synchronously one line before backfillSidecarIfMissing runs, so that described nearly every entry rather than a rare few. poll() had already removed them. maybeDrainSidecarBackfill replaces both: a Mutex-guarded drain that is safe to call from anywhere, triggered by the enqueue, the token handler, and the update check's own finally. In-flight entries are re-queued instead of discarded, and the per-pluginId attempted set replaces the one-shot AtomicBoolean as what bounds the work. That repeatability needs a bound of its own, because getDownloadUrl is not read-only: it books a plugin_downloads row, which feeds the store's default sortBy = "downloads" ranking. The attempted set bounds it within a process. For the genuine-mismatch case, which by design never resolves and so retried on every launch forever, fetchStoreSignature now separates a settled answer from an unsettled one and a .nosig marker records the settled one. It is keyed on the full pluginId|version|sha256 anchor, so it self-invalidates the moment any of the three changes, and PluginSignatureSidecar.delete removes it with the sidecar so no call site gains a cleanup obligation. The signature-only store route that would remove the cost rather than remember it is an edge-function change and is not in scope here. Also from the same review: - downloadOnly plugins are no longer signed on the download path. The microkernel runtime is the only one, PluginLoaderDelegateImpl refuses to install it as a plugin so it never reaches the loader's signature check, and signing it spent a getDownloadUrl and logged a warn every launch for a JAR that most likely has no store row. This matches backfillSidecarIfMissing, which was already only called for non-downloadOnly plugins. - Two sidecar deletes are now gated on the JAR delete having succeeded, like the download path's sweep already was. In the bundled-copy sweep that is a real signed-to-unsigned flip whenever the JAR is held open. - The second full SHA-256 pass over the JAR is replaced by a size and mtime stamp taken beside the first hash. Both replacement paths change mtime, and this one runs at startup over JARs as large as editor-tab and fluck-browser. The millisecond-resolution blind spot and the newly stricter treatment of a same-version re-download are both documented and pinned by tests. - PluginSignatureSidecar.write stages through a unique temp file. Two writers racing one JAR shared a fixed .sig.tmp and could interleave into a half-written sidecar, which is a present-but-invalid signature and so a harder failure than the missing one it was replacing. ConcurrentSidecarWriteTest fails on the old code and passes on the new. Co-Authored-By: Claude Opus 5 * fix(plugins): defer lifecycle to #347 and retain digest revalidation * test(plugins): propagate concurrent sidecar reader failures * style(tests): format concurrent writer futures * fix(plugins): keep cached mismatch visible and retry under enforcement * Read a sidecar without an exists() pre-check, so a concurrent write cannot throw The Windows CI leg failed ConcurrentSidecarWriteTest with FileNotFoundException out of PluginSignatureSidecar.read. The test is doing its job: the defect is in read, not in the test. read did `if (f.exists()) f.readText()`, which is a time-of-check-to-time-of-use race against write. Files.move with REPLACE_EXISTING transiently unlinks the target on Windows, so a reader that passed exists() could reach readText() after the unlink and throw out of a function every caller treats as total. Callers include the load path, so on Windows a plugin load overlapping a background sidecar write could fail for a reason that had nothing to do with the plugin. Reading straight through runCatching removes the window: an absent file and a mid-replace file both answer null, which is what the pre-check was trying to express. Every failure now reads as "no sidecar", which is the safe direction and matches the policy stated elsewhere in this file: a MISSING sidecar is warn-and-allow, while a present-but-invalid one hard-fails at load. It grants nobody anything new, since anyone able to make a sidecar unreadable could delete it instead. Co-Authored-By: Claude Opus 5 * fix(plugins): serialize sidecar reads and replacements on Windows * Fix API guard false positives for private lambda accessors (cherry picked from commit 9f08f55cc96918c95a72f6f330ec7b2f85e9806a) * test(sandbox): await asynchronous disable notification --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * Do not skip a declared minApiVersion when the API layer is unresolved (#395) * Do not skip a declared minApiVersion when the API layer is unresolved satisfiesMinApiVersion delegated to satisfiesVersionFloor, which answers true for a blank or unparseable installed version. hostApiVersion reads System.getProperty("boss.api.version"), published only once the API plugin resolves, so a check that runs before then skipped the floor entirely and the update was offered and installed. The loader rejects it on the same floor afterwards, which leaves a swapped jar the host cannot load instead of no update: the Toolbox 1.8.4 on BOSS 9.2.25 failure the surrounding comments cite. Concretely, terminal-tab 2.5.74 declares minApiVersion 1.0.88 and implements renameTab, tabActivity and the initialCommand split overloads against it. On a host still carrying API 1.0.87 those symbols are absent. Now fails closed, but only when a floor is actually declared. A blank minApiVersion still passes, because every version published before the column existed carries "" and gating those would withhold every update on a host whose API had not resolved. An unparseable minApiVersion also still fails open, since that value comes from the store and one malformed row should withhold nothing. satisfiesVersionFloor itself is unchanged. Its three other callers want the fail-open answer: the home grid prefers showing a tile, and both retirement checks wrap it in their own fail-closed logic, with tests pinning exactly that. * Address review: fix the guard order and stop withholding updates forever Four things the review on #395 got right. The guard order was a real bug. Asking about the installed version first meant a malformed minApiVersion plus an unresolved API layer withheld the update, while the KDoc claimed a malformed floor always fails open. Parsing `required` first makes the two documented fail-open cases unconditional, and since parse rejects blank it also folds in the no-floor check. Blank was not the transient state the fix assumed. ApiClassLoader.apiVersion is null when no api jar was found, which the host published as "", so a host in that state would have been denied every floor-declaring plugin update for the whole session, every session. hostApiVersion is now () -> String? and the host passes the property through without `?: ""`, so null (not resolved yet) fails closed while "" (resolved, no api jar) keeps the answer it always gave. That also matches DynamicPluginLoader, which skips minApiVersion validation outright when currentApiVersion is null rather than rejecting - so the "loader is the backstop" claim in the old KDoc was wrong for exactly this case. An unreadable installed version now logs its own WARN. It is a property of the host, not of the candidate, and "Skipping plugin update requiring newer API layer" pointed the reader at the plugin. Since a withheld update is otherwise close to invisible, the reason has to be diagnosable. hostApiVersion() was read three times per rejected candidate, and the api layer resolving is precisely the event this branch is about, so the notice could report a version that would have satisfied the floor the check just failed. Read once for the sweep and pass it down. Tests: pin both malformed-floor combinations (the ordering bug), the resolved but absent api jar case, and a prerelease installed version, which satisfiesVersionFloor deliberately ignores and nothing pinned for this gate. The three near-identical fake repositories in this module are now one shared FakeSingleVersionRepository. Both new assertions were confirmed to fail against the previous implementation before being kept. * Add a deterministic tiebreaker to the secret paging RPCs (#385) get_user_secrets, search_user_secrets and get_user_secrets_with_shared all page with ORDER BY s.created_at DESC LIMIT p_limit OFFSET p_offset and nothing else. secrets.created_at is timestamptz DEFAULT now() with no unique constraint, and now() is the transaction clock, so every secret written in one transaction shares it exactly. That makes the sort key a partial order, and Postgres may emit tied rows in any order the plan happens to produce, differing between two calls. LIMIT/OFFSET then slices that unstable sequence. A tied row that sat last on page 1 can land one position earlier on the next call, inside the slice the caller already consumed and before page 2's OFFSET, so a full walk returns it zero times and some other tied row twice. Nothing errors. A plugin MCP tool that walks get_user_secrets_with_shared by offset looking for a secret by id reports "no secret with id X" for a secret that exists. Appending s.id DESC fixes it: id is the primary key, so unique and NOT NULL, and (created_at DESC, id DESC) is a total order. Every row then has exactly one position, the same query returns the same sequence, and page boundaries line up with no gap for a row to fall into. 20260307000002_fix_views_security_invoker.sql applied the same reasoning to plugins_with_latest_version. A new migration rather than an edit to the 20260802* files, which are applied on production. Replacing a function body needs the whole definition, so all three are reproduced verbatim from their current migrations with only the ORDER BY line changed; each body was extracted mechanically and diffed against its source to confirm that. No index. (created_at DESC, id DESC) on public.secrets would let the planner read the order instead of sorting per page, but that is a separate change with its own write-amplification and locking profile, and the order is total without it. * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * feat(remote-ui): a wants_keys capability gate for out-of-process plugin surfaces (#441) * feat(remote-ui): a wants_keys capability gate for out-of-process plugin surfaces A remote surface with an interactive widget (a text field, a button) receives every unclaimed key press once that widget holds focus - KeyEvent forwarding was never conditioned on anything the plugin declared. docs/KEYBOARD_SHORTCUTS.md already names this as a known, unbuilt gap, and the reviewers of #51 (which first wired KeyEvent onto the wire) went further: RemoteWidgetRenderer.kt's own KDoc records that an earlier revision made the surface itself directly focusable, was rejected in review as 'the wrong default to ship', and named the fix 'granting it later behind a declared wants_keys on UIRegistration' as the additive follow-up. That flag was never added - the mitigation that shipped instead (the surface itself is not a focus target) only closes half the gap: any surface with so much as one interactive child widget already gets every unclaimed key while that child has focus, regardless of whether the plugin has any reason to see one. This adds the flag reviewers specified, and closes the real gap rather than the narrower one: UIRegistration.wants_keys (proto field 8, false for every plugin built against an older host - additive, not a breaking change). False is the default and the fail-closed answer throughout: RemoteUiSurfaceDescriptor carries it from registration, RemoteSurfaceComponent re-reads it off the registry on every host callback (registration can race a host attaching first, so there is no single 'read once' moment safe to hardcode), and RemoteWidgetRenderer omits the key-forwarding modifier entirely for a surface that never declared it - not attach it and have it decline everything, but never install a code path that sees a key at all. A plugin gains nothing by omission: unset reads as false in proto3, matching every plugin that predates this field. Tests: RemoteUiSurfaceRegistryTest pins the descriptor round-trip through registration. RemoteSurfaceInputComposeTest adds the negative case beside the existing positive one - same widget tree, same focus, same unbound chord, the only difference being the registration under test - proving no Key event reaches a plugin that left wants_keys unset, through the real registry -> component -> renderer pipeline, not just the routing rule in isolation. * fix(remote-ui): enforce key capability at the receiving queue * fix(remote-ui): deliver capabilities without callback registry reentry * test(remote-ui): pin key capability teardown and clarify contracts --------- Co-authored-by: Shivang * fix(search): honor typed case and avoid separator-list allocations (#453) * fix(search): correct exact-case scoring and eliminate heap allocations in FuzzyMatcher * fix(search): preserve typed case through global search scoring * style(search): use expression bodies after case preservation * fix(search): preserve indices for expanding Unicode lowercase * refactor(search): clarify casing contracts and remove redundant guard * docs(search): state matcher index and cached casing contracts --------- Co-authored-by: Shivang * test(tabs): preserve the all-open tab inventory contract (#438) * fix(tabs): track only visible active tabs in split views * fix(detekt): suppress PackageNaming and fix EmptyFunctionBlock in test * test(tabs): preserve complete inventory for background tab consumers * test(tabs): cover unselected panels and keep assertions readable * style(tests): apply ktlint formatting to inventory assertions * test(tabs): clarify workspace scope and cover cross-workspace duplicates --------- Co-authored-by: Shivang * fix(crash): resolve contained-fault attribution on the faulting thread (#456) * fix(crash): resolve contained-fault attribution on the faulting thread A contained render fault raised while the host was inside a plugin call was written to ~/.boss/crash-reports as "(unattributed)", so the report could not be correlated with the plugin it belonged to. That is the missing half of BossConsole#394: the report reached disk, it just did not say who to blame. CrashHandler.attributePluginId has three ranks - the boundary tag, the thread's plugin scope, then the classloader scan. Only rank 2 is thread- affine: PluginExecutionBoundary.currentPluginId reads a ThreadLocal. recordContained hands report building to the boss-contained-crash-writer thread, where that ThreadLocal is always empty, so rank 2 was structurally dead on this whole path. Rank 1 could not cover for it either: a fault the host caught never escapes a runAttributed frame, so nothing tags it. The ladder fell through to the classloader scan, which finds only host frames when host code faults on a plugin's behalf - exactly the reported case. The writeInline branch runs on the calling thread and so read the scope correctly, which meant one fault attributed differently depending on whether the process was about to exit. recordContained now samples the scope where it is valid and passes it down, alongside the report directory it already resolves on the caller for the same class of reason. Only the scope is captured: the other two ranks key off the throwable and are correct on any thread, and the classloader scan is the expensive step this path deliberately moved off the EDT. attributePluginId takes the sampled scope as a defaulted parameter, so the ladder order is unchanged and every existing caller keeps reading it on its own thread. A captured null is indistinguishable from finding no scope, so a genuine host fault stays unattributed. Three tests: a fault reported inside a scope and never escaping it (so no tag can mask the bug) attributes on the async path; inline and deferred writes agree for one fault; a host fault outside any scope stays "(unattributed)". Touches neither the restart-limit persistence of #436 nor the browser listener guards of #400 and #442. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SPf93EJ8Yj1E6HmVTXCE5D * fix(crash): resolve Detekt code quality violations in attributePluginId * style: convert attributePluginId to expression body to satisfy ktlint * test(crash): pin captured attribution priority and null semantics * docs(crash): clarify contained attribution scope and prune baseline * test(crash): separate captured scope invariants and clarify docs --------- Co-authored-by: Claude Co-authored-by: Shivang * test(ipc): await subscriber readiness before publishing a batch (#433) * fix(ipc): retry publishBatch until the subscriber has received something publishBatch delivers all events used a single publishBatch call after only awaitSubscriberRegistered(), a weaker guard than the file's own documented pattern - the RPC handler running is still ahead of the flow being collected, and MutableSharedFlow has no replay, so a publish landing first is lost for good. Flaky specifically on the Windows CI runner, the slowest leg. Retry only while nothing has arrived, since republishing an already-arriving batch would inflate the count this test asserts exactly. * test(ipc): await a readiness probe before publishing the batch * test(ipc): unify batch size and fail fast on ended subscribers --------- Co-authored-by: Shivang * fix(plugins): exempt bundled plugins from store-signature enforcement (#102) (#435) * fix(plugins): exempt bundled plugins from store-signature enforcement (BossConsole#102) Flipping PluginSignatureEnforcement.enforceUnsigned on would hard-fail loading every bundled plugin - they ship inside the signed, notarized app image (downloaded from GitHub releases at build time, not through the signed store), so none carry a .sig sidecar today. That is the first of two blockers the issue names for the flip; this closes it. Adds PluginBundledTrust: a content-addressed .bundled-trust marker, written only by PluginStoreSetup's bundled-copy step right after copying from the trusted bundled directory, and checked by verifySignatureOrThrow as a permanent (not rollout-scoped) exemption. Binding to sha256 means a marker cannot be forged by plugin content, and does not survive the file at that path being replaced by something else. Does NOT flip the enforceUnsigned default. The issue's second blocker - sidecars backfilled across the installed base via #101 - only covers the curated system_plugins list, not every previously-installed store plugin, and whether that backfill has actually reached the fleet is a production-database fact this environment cannot verify. Flipping blind risks the exact failure mode the issue names: the Toolbox itself becomes unloadable. That remains a one-line flag flip for whoever can confirm backfill coverage. * fix(plugins): bind existing bundled copies to trusted source bytes * style(tests): format bundled trust regressions * fix(plugins): preserve bundled trust through reconciliation and rollback --------- Co-authored-by: Shivang * fix(paths): return no parent for bare filenames (#421) Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(security): encrypt TOTP secrets at rest (#417) * fix(security): encrypt TOTP secrets at rest * fix(security): apply TOTP RPC upgrade and restrict helper grants * test(security): expose real upgrade migrations to pgTAP container * test(security): assert anonymous RPC execution is denied * test(security): document and verify TOTP writer boundaries * test(security): make TOTP review checks portable and explicit --------- Co-authored-by: john k Co-authored-by: Shivang * Fixes #147: Harden Supabase response decoding (#351) * Fixes #147: Harden Supabase response decoding - Add coerceInputValues = true to supabaseJson - Add per-row recovery helper decodeListRecovering - Replace decodeFromJsonElement with decodeListRecovering in SecretService and RoleService - Sanitize RestException in sanitizeSupabaseFailure - Expand strict Json guard repo-wide in SupabaseWiringTest * fix(supabase): satisfy ktlint formatting * Fix recovery diagnostics and preserve paginated secret offsets * Address decoding review failure boundaries and guard coverage * test(supabase): pin recovery and coercion safety boundaries --------- Co-authored-by: Shivang * fix(updater): remember unsupported macOS refusals and clean up downloads (#343) * fix(updater): dismiss unsupported macOS update * chore(updater): document installer size exception * fix(updater): consolidate refusal cycle coverage and restore artifact guards Adapt the check-download-refuse-recheck regression from Antriksh1984 in PR #440 (64b172bd5) to the existing typed outcomes and isolated settings in PR #343. Preserve Ayush04-C original implementation and subsequent revisions in history. Reviewer repairs restore stale-path matching and four lost regression tests, prevent cleanup of a restaged path, run cleanup for refused downgrades and canceled persistence, isolate cycle settings, and close test managers. These repairs are separate from the original contributor work. Co-authored-by: Antriksh1984 * refactor(updater): flatten refusal cleanup branch for detekt * fix(updater): retain newer refusals and serialize artifact ownership Do not let an intermediate release erase a newer suppressed version. Publish install outcomes only while that install still owns state. Serialize downloads, installs and discards across suspension so a refusal cannot delete bytes from a subsequent download; stale install/discard requests do not consume a later staged state. Reviewer regressions cover the intermediate version, retained staged metadata, same-path reuse and a download waiting for refusal cleanup. The banner Retry action remains an explicit forced check; manual unsupported-macOS verification is still pending. * refactor(updater): simplify the preserved-dismissal predicate * style(updater): wrap concurrency regression assertions * style(updater): apply verified formatter output * fix(updater): retain active download and restart ownership --------- Co-authored-by: ayush Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(browser): apply context menu setup to all created browser instances (#445) * fix(browser): apply context menu setup to all created browser instances * fix: CI workflow * fix(browser): cover fallback suppression and keep review scoped * refactor(browser): separate popup window construction from menus * refactor(browser): isolate shared chrome entry point * refactor(browser): share context-menu suppression and response handling * fix(browser): preserve popup rationale and isolate setup failures * test(browser): verify cleanup when initial chrome setup fails * refactor(browser): express failed setup cleanup with finally * fix(browser): apply cleanup consistently and resolve review leftovers * style(browser): brace regression test branches consistently * fix(browser): release managed profile after fatal chrome setup failure --------- Co-authored-by: Shivang * fix(secrets): model organisation ownership fields on secret entries (#356) * fix(secrets): model organisation ownership fields on secret entries get_user_secrets, search_user_secrets, get_user_secrets_with_shared and get_secret_shares were extended with org_id/org_slug/is_org_owned/can_manage (and shared_with_org_id/shared_with_org_slug) by the organisation migration, but SecretEntry/SecretEntryWithSharing/SecretShareEntry never gained fields for them - decoding silently discarded the columns (#146). The concrete bug: an org admin who did not create a secret has is_owner=false and access_level="org", a value the client has never modeled, with nothing to tell a caller they are still allowed to manage it. can_manage is what fixes that once a UI reads it - nullable and fail-closed (null/absent means "cannot manage"), matching the server's own can_manage_secret() semantics. Adds a decoding test for the access_level="org" case directly, plus coverage of every new column across all four RPCs. Also fixes a pre-existing test canary in SupabaseJsonTest that happened to use org_id as its "unknown key" example - now that the key is modeled, it no longer exercises the unknown-key path, so the canary was moved to a still-genuinely-unmodeled column name. Out of scope, and left for follow-up in other repositories: the Secret Manager UI itself (edit/delete affordances gated on can_manage, showing org ownership in the list) lives entirely in the separate secret-manager plugin repo, not here. The plugin-facing SecretEntryWithSharingData/SecretShareData classes come from the externally published, version-pinned boss-plugin-api jar and have no org fields either - those need a new upstream release before a plugin can see any of this. This PR only fixes the host-side model, which is the part that can be done inside BossConsole itself. * Correct organisation sharing semantics and pin unknown permissions * Add canManageOrDeny fail-closed accessor to secret models Suggested in review: the fail-closed reading of the nullable can_manage column (unknown/null means "cannot manage") lived only in a comment, so a caller had to reimplement `canManage == true` at every gate rather than grepping for one name. Added to both SecretEntry and SecretEntryWithSharing. Extends the existing absent/explicit-null test to also pin canManageOrDeny == false in both cases, plus a positive case in the get_user_secrets decoding test. * test(secrets): cover explicit organisation management denial --------- Co-authored-by: Shivang * Fall back to the plugin store when GitHub cannot supply a system plugin (#420) * Fall back to the plugin store when GitHub cannot supply a system plugin system-plugins.json routes the headline surfaces (Terminal Tab, Fluck Browser, Editor Tab) through GitHub Releases, and an unauthenticated host gets 60 API calls an hour shared across everything on its egress IP. A CI runner, an agent VM or an office NAT burns that quickly, and downloadSystemPluginFromGitHub treats the resulting 403, the non-2xx, and a release with no matching JAR asset as terminal. The caller logs "Failed to download system plugin" and moves on, so a signed-in BOSS comes up with no terminal, no browser and no editor, while the same plugins install fine from the store moments later. Those three branches now record the plugin as store-repairable with the concrete reason, and a deferred pass installs it from the store. Deferred is the whole point. PluginStoreConfig.accessToken is still null while ensureSystemPluginsInstalled runs from initialize(), and the store download route 403s an unauthenticated caller for any plugin with non-empty requiredPermissions, which is exactly the set this targets. An inline fallback would fail for the same plugins on every launch and look like the fix was in. The pass therefore hangs off the same auth trigger the sidecar backfill uses, but is not one-shot: GitHub failures can be queued at any point, including from the realtime manifest path long after the first token arrived. The minVersion branch is deliberately NOT marked repairable. It refuses a release the host has declared too old, and reaching around it to the store would install the exact contract-breaking JAR the gate exists to prevent. Repairs go through RemotePluginRepository.downloadPlugin, which enforces the store signature, so a repaired JAR arrives signed even though the GitHub path it replaces carries no sidecar. An existing enabled=false is preserved, because repairing a missing file must not re-enable something the user turned off. Both outcomes report at WARN carrying the GitHub reason that led there. The silent version is what #399 was filed about: the wizard's log said only "Some plugins failed to install", so an operator could not separate a rate limit from a missing asset without reading the host log. Scope: the repaired JAR is not loaded into the running session, so it becomes active on the next launch. Loading it live needs a DynamicPluginManager, and those are per-window while PluginStoreSetup is process-wide, so holding one here would pin a window's manager and its classloaders for the life of the process. That is a leak this repo already guards against, and it is not worth introducing to save a restart. Directions B and C from the issue are out of scope and said so on the issue. Co-Authored-By: Claude Opus 5 * Validate and safely promote deferred store repairs * Fix repair test formatting and keep cleanup in finally * Repair system plugins after GitHub transport failures * refactor(plugins): clarify store repair control flow * fix(plugins): contain store staging creation failures * docs(plugins): retain authentication wake-up rationale --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * refactor(plugins): split two self-contained pieces out of PluginStoreSetup.kt (#447) (#448) * refactor(plugins): split two self-contained pieces out of PluginStoreSetup.kt PluginStoreSetup.kt is a 2,089-line god object owning six unrelated concerns (#447). Rather than attempt the full eight-step refactor in one pass, this extracts the two pieces that are safely, mechanically verifiable without a live app run: - SidecarBackfillCoordinator (step 7): already a standalone class driven entirely by constructor lambdas, with zero references to PluginStoreSetup's own state. Moved to its own file in the same package, so no caller or test needed to change - a pure file move, not a behavior change. - isNewerVersion, isTooOldForHost, pickPluginJarUrl and extractVersionFromJarFileName (step 6): four pure, stateless functions already `internal` for test access. Extracted into a new PluginVersionComparator object; the ~9 internal call sites and the test file (renamed PluginVersionComparatorTest, same assertions) were updated to match. PluginStoreSetup.kt drops from 2,089 to 1,894 lines. The remaining six steps (GitHubReleaseClient, SystemPluginInstaller, PluginSignatureResolver, BundledPluginCopier, readPluginManifest relocation, and the full initialize()/loadPersistedPlugins()/shutdown() orchestrator shape) are not attempted here - several of them touch the plugin-install and startup-restore paths the issue's own acceptance criteria says to verify via `./gradlew run`, which this environment cannot do. * Keep extraction references current and simplify the preserved condition --------- Co-authored-by: Shivang * fix(paths): preserve roots and normalize repeated parent separators (#422) * fix(paths): return no parent for bare filenames * fix(paths): preserve roots and normalize repeated parent separators * test(paths): verify root identity through tab comparison and clarify limits * test(paths): report platform skips and clarify lexical label rules --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(urls): reject out-of-range ports in OS-opened URLs (#425) * fix(urls): reject out-of-range ports in OS-opened URLs * test(urls): clarify numeric ports and cover padded invalid values --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * feat(plugins): resolve and install a missing dependency's whole closure from one dialog (#429) * feat(plugins): resolve the transitive install plan for a missing dependency * feat(plugins): let the store-backed installer plan and install a dependency closure * feat(plugins): show and install the whole dependency closure from one dialog * docs(plugins): record that the dependency dialog now resolves and installs the closure * fix(plugins): preserve dependency consent and detached plan completion * fix(plugins): report detached plan errors and reset prompt names * fix(plugins): propagate display-name lookup cancellation --------- Co-authored-by: Shivang * feat: improve plugin onboarding wizard (#443) * feat: improve plugin onboarding wizard * fix: resolve ktlint violation in wizard tests * fix: resolve ktlint import ordering * fix(onboarding): keep requirement labels visible and clarify install failures * fix(wizard): preserve selected styling for required tools * fix(wizard): keep scaled completion content and card semantics accessible * test(wizard): assert required label bounds in the unmerged tree --------- Co-authored-by: Shivang * fix(plugins): scope OOP process IDs by window (#449) * fix(plugins): scope OOP process IDs by window * fix(build): exclude OOP process identity tests on Windows ARM64 * test(build): keep ARM64 source exclusion mirror in sync * fix(plugins): bound window process IDs and preserve plugin metadata * fix(plugins): preserve process diagnostics with opaque identities * test(plugins): pin performance process reflection contracts --------- Co-authored-by: Shivang * fix(plugins): don't delete a superseded jar a live classloader still has open (#431) * fix(plugins): defer removing a jar a live classloader still has open (#72) Rebuilt against the current reconciler after main absorbed an unrelated scoping refactor (pluginIds parameter, readCandidates/repointInstalledEntry extraction) that conflicted with this branch's stale base. Same fix as before: PluginClassLoader.isPathOpenByLiveLoader(path) plus a ReconcileResult.deferred list, wired through the extracted reconcileLoser so a superseded jar still open by a live classloader is left in place and swept on a later reconcile, instead of breaking pty4j's own-jar reopen. * fix(plugins): publish loader lifecycle state before reconciliation * fix(plugins): enforce close-only unloaded transition * fix(plugins): retain live jar protection through classloader close * docs(plugins): explain classloader close-state invariants * docs(plugins): qualify resource availability during close --------- Co-authored-by: Shivang * fix(plugins): restore imports required by store repair integration * feat(cli): add plugin init, validate, and link commands with dev hot-reload Adds `boss plugin init`, `boss plugin validate`, and `boss plugin link` to the CLI so developers can scaffold, check, and test plugins without manual staging or restarting the desktop app. ### Changes #### Project scaffolding (`boss plugin init `) - Scaffolds a buildable Gradle Kotlin Multiplatform plugin project using one of four templates: `mcp-tool`, `ui-panel`, `background-service`, or `full`. - Resolves the host `pluginApiVersion` dynamically, with fallback to compile-time constants. - Supports `--dir` for custom target locations, `--template` to select structure, and `--json` for automation. #### Manifest and bytecode validation (`boss plugin validate []`) - Inspects plugin source directories or packaged `.jar` files. - Checks manifest syntax, semver formats, required IDs, and declared `minApiVersion`. - Verifies that bytecode entrypoints exist on the classpath and implement expected plugin interfaces. - Checks requested permissions against canonical Supabase permissions (migration added in `supabase/migrations/20260908000000_canonical_plugin_permissions.sql`). #### Staging and live reload (`boss plugin link []`) - Stages the compiled plugin JAR into `$BOSS_HOME/plugins/dev//v/.jar` (requires running `./gradlew build` first when linking from a source directory). - Dispatches `PLUGIN_DEV_RELOAD` across the local single-instance socket to reload the plugin live if BossConsole is running. - Exits 0 and leaves staged files on disk when the host is offline, ready for the next startup. #### Startup deduplication and packaging fixes - `DefaultPlugin.kt` groups dev JARs by plugin ID and selects the latest timestamp directory. Legacy flat JARs in `$BOSS_HOME/plugins/dev/` are discarded when a version-rotated JAR exists, preventing duplicate tool registrations. - Bundles `version.properties` into `composeApp/src/desktopMain/resources/` so dynamic API version lookups succeed when the CLI runs outside the repository root. - Caps exception cause traversal in `SingleInstanceManager.kt` with `.take(15)` to prevent infinite loops from circular causes. - Enforces `StandardCharsets.UTF_8` across all IPC socket readers and writers. - Sets file mode `100755` in the Git index for `scripts/boss` and `scripts/test/test-headless-cli.sh`. Added `.gitattributes` to enforce LF on all shell scripts. ### Verification - `PluginManifestTest`: 11 passed, 0 failed. - `PluginEndToEndEvalTest`, `PluginValidatorEvalTest`, `PluginScaffolderEvalTest`: 19 passed, 0 failed. - `scripts/test/test-headless-cli.sh`: 13 test suites passed. - `./gradlew detekt ktlintCheck --no-daemon`: passed with 0 violations. - `./gradlew testClasses --no-daemon`: passed across all 52 tasks. - `git diff --check`: passed with no whitespace or line-ending warnings. * fix(plugins): route host URLs within the requesting instance * fix(cli): reject development reload when host handler is absent * fix(logging): stop a sensitive URL param's colon from leaking a token fragment (#471) * fix(logging): stop a sensitive URL param's colon from leaking a token fragment filePathPattern's [^\s:]+ stops at the first colon (correct for a host:port boundary elsewhere in this file), so a sensitive query or fragment value that happens to contain one - token=abc:def - was only partly consumed. The tail survived sanitizeExceptionMessage verbatim: [url=https://.../cb?token=abc:def] became [url=https:[PATH]:def], leaking a fragment of the token. Measured and reported against #109. Adds a narrow pre-pass that redacts a sensitive-named query/fragment param (by the same nameMarksSecret check the existing name=value pass already uses) before the location passes run, so nothing with a colon in it reaches filePathPattern's boundary in the first place. A non-sensitive param's colon is untouched - out of this fix's scope, and pinned as a regression guard. * fix(logging): consume punctuation within sensitive URL values * fix(logging): reuse URL credential names in the redaction pre-pass * style(logging): wrap URL sensitivity predicate --------- Co-authored-by: Shivang * fix(browser): stop the secret-suggestion scorer over-matching unrelated sites (#460) (#461) * fix(browser): stop the secret-suggestion scorer over-matching unrelated sites WebsiteMatchingUtil.calculateMatchScore decides which saved secrets the Fluck browser suggests for the current page. Three bugs let it offer the wrong site's credentials (#460): - An unanchored `secretNorm.contains(domainNorm)` check scored 0.7 ("domain") for pure substring hits with no word boundary, so a secret for apple.com matched the unrelated site snapple.com. Removed - the exact and subdomain-boundary checks above it already cover every legitimate case a substring test was trying to catch. - The token-overlap "partial" fallback split domains on ".", "-", "_" and treated any shared label as a match, including bare TLDs - so every ".com" secret matched every ".com" site via the shared label "com". Now filters known TLD/public-suffix-ish labels out of both sides before intersecting. - getDisplayName's `replaceFirstChar { c -> ... else it }` returned the whole outer word instead of the char `c` for a non-lowercase first character, duplicating the word for any digit-initial domain name (1password.com -> "1passwordpassword", 9gag.com -> "9gaggag"). Fixed to return `c.toString()`. Scope: this closes the three findings and matches the exact test cases named in the issue (snapple.com must not match apple.com, a .com secret must not match an unrelated .com site, 1password.com's display name). Left alone on purpose: the token-based partial match still lets a genuinely shared non-TLD word match at a low 0.5 score (e.g. "google-workspace.com" still matches "google.com", by original design), which also means a case like login-apple.com still weakly matches apple.com via the shared word "apple" - the issue's own required test list doesn't ask for that case to close, and doing so would mean rethinking the partial-match tier's purpose rather than fixing the three named bugs. WebsiteMatchingUtil had no test coverage at all before this change. * fix(browser): drop the token-overlap partial-match tier entirely Review feedback from the #460 reporter: excluding bare TLD labels from the "partial" fallback's shared-token check closed the "every .com secret matches every .com site" case, but the fallback still matched two different registrable domains that merely share a brand-ish label (apple.com vs apple.org, google.com vs google-workspace.com) - the same wrong-site-credential problem the original fix targeted, just narrower. Adopts the suggested fix: calculateMatchScore now recognizes only exact registrable-domain equality and a real subdomain boundary (one side is a dotted suffix of the other), matching what a password manager keys on. The token-overlap tier and its COMMON_TLD_LABELS allowlist are removed rather than patched further - a credentials surface should favor precision over recall, and no shared-word heuristic closes that gap for good. Also added the empty-string guard from the suggested diff, so two blank sides (an unresolved domain, a secret with no recorded website) can't vacuously exact-match each other. Test file extended per the suggested test list: apple.com vs apple.org, login-apple.com vs apple.com (previously the one case the TLD-only fix left open), a shared-TLD pair, www normalization on both a positive and a negative case, and the end-to-end matchSecretsForDomain offer path - alongside the existing exact/subdomain/display-name coverage. * test(secrets): retain domain regression coverage from PR 467 Retain Sahvendra7 tests alongside Antriksh1984 coverage, and clarify the existing extractor limitation. Consolidation and documentation edits by review agent. Co-authored-by: Sahvendra * docs(secrets): align integration guide with strict matcher --------- Co-authored-by: Shivang Co-authored-by: Sahvendra * fix(editor): resolve run-main language from the file path, not the stored name (#458) (#459) * fix(editor): resolve run-main language from the file path, not the stored name (#458) executeMainFunction resolved the language with Language.fromExtension(mainFunction.language), but MainFunctionInfo.language is serialised as Language.name.lowercase() - a name like kotlin, not a kt extension. fromExtension matches extensions, so every language whose enum name is not also an extension resolved to UNKNOWN, and generateCommand maps UNKNOWN to the literal command echo 'Unknown language'. Only java and go worked, by coincidence. Resolve from mainFunction.filePath instead (via a testable runLanguageForMainFunction helper), matching how detectMainFunctions already derives it. Covered by RunLanguageForMainFunctionTest. * test(editor): cover alternate run-main extensions and path precedence * style(editor): wrap run language helper for detekt * docs(editor): fix KDoc link without changing helper signature --------- Co-authored-by: Shivang * fix(deep-link): report whether a plugin action link actually ran, not a blind OK (#470) * fix(deep-link): report whether a plugin action link actually ran, not a blind OK boss://plugin?id=&action= dispatched to DeepLinkActionRegistryImpl fire-and-forget: the boolean verdict (handler found? action accepted?) was discarded, so SingleInstanceManager always answered a forwarded action link with RESPONSE_OK - even when no handler was registered for the given id, or the handler declined the action. A caller scripting BOSS via boss:// links (CLI, another instance, an agent) had no way to tell a real success from a silent no-op. processDeepLink now returns a Deferred? - null for every route except a plugin action link, preserving today's fire-and-forget behaviour everywhere else. SingleInstanceManager awaits it (bounded, well under the socket's own close budget) and reports a real ERROR instead of OK when the action was never actually handled. * fix(deep-link): bound action dispatch and prevent automatic replay * refactor(deep-link): isolate action response formatting for quality gates --------- Co-authored-by: Shivang * fix(deep-link): stop logging Linux's expected APP_OPEN_URI gap as an error (#437) setupDefaultHandler() (Linux and other non-mac/Windows platforms) caught Desktop.setOpenURIHandler's UnsupportedOperationException with the generic Exception branch, logging it at ERROR with a full stack trace on every Linux launch. This is a documented, expected outcome - the JDK's X11 Desktop peer supports no APP_OPEN_URI action - and delivery already works via processCommandLineArgs reading argv, both at cold start and forwarded to a running instance over the single-instance channel. Mirrors the catch order setupOpenFileHandler already uses for the equivalent APP_OPEN_FILE gap on the same platforms: UnsupportedOperationException first, logged at DEBUG as non-error, before the generic Exception catch. * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Address project announcement review feedback * fix(cli): integrate plugin launchpad with host loader and runtime - Target canonical ai.rever.boss.plugin.api.Plugin and META-INF/boss-plugin/plugin.json in scaffolder - Wire DevPluginReloader on Dispatchers.Main and handle offline host cleanly - Stage plugin jars atomically via .part and preserve active versions on failure - Validate bytecode with ASM ClassReader without running static initializers - Bundle gradle-wrapper.jar for standalone init outside the repository - Read PLUGIN_API_VERSION directly from gradle/libs.versions.toml and drop redundant version.properties - Remove premature RBAC permissions SQL migration and test * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and nam… * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog (#469) * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog McpPolicyEngine.setToolPolicy already writes a durable ALLOW/ASK/DENY rule to ~/.boss/mcp-tool-policy.json and takes effect immediately - it was fully built and tested, but the only caller was this repo's own test suite. AGENTS.md's own governance section names the gap plainly: "Persistent rules currently require editing ~/.boss/mcp-tool-policy.json and restarting." The interactive approval dialog for ASK-governed tools offered only "Approve Once" and "Always this Session" (in-memory, cleared on restart) - there was no way to tell BOSS "never ask about this tool again" without hand-editing JSON. - McpApprovalDecision.Approved/Denied gain a persistPolicy flag, threaded through McpApprovalBus.approve/deny (both additive, defaulting to false - every existing call site is unaffected). - McpToolRegistryCore.authorizeInvocation calls policyEngine.setToolPolicy(toolName, ALLOW/DENY) when persistPolicy is set, recording two new McpApprovalDisposition values (PERSISTENTLY_ALLOWED / PERSISTENTLY_DENIED) in the audit ledger so it's visible which of the three scopes - once, session, persistent - an operator actually chose. A persisted ALLOW makes the existing session-trust call redundant (policyFor checks the configured rule first), so the registry does not also call trustForSession when this is set - one less write, same result. - McpApprovalDialog gains "Always Allow" / "Always Deny" actions, visually secondary to the three existing per-call actions since they are the more consequential, less common choice. Scope: this is the interactive path only. The policy file itself, its fail-closed-on-unreadable behavior, and the kill-switch/RBAC layers upstream of policy resolution are untouched. 8 tests in McpGovernedInvocationTest (2 new) cover persisted ALLOW and DENY end to end: the approval/denial itself, the ledger disposition, and that a second call to the same tool no longer suspends for approval - confirming the rule actually took effect, not just that it was written. ktlintCheck, detekt and the full mcp/app/dialogs test packages all green. * fix(mcp): verify durable approval writes and preserve revocations * feat(mcp): let an operator inspect and revoke a persisted tool policy The review's remaining finding: after "Always Allow" persists a rule, there was no way back except hand-editing ~/.boss/mcp-tool-policy.json and restarting - the new caption and AGENTS.md text explained that, but disclosure is not a reversal control, and "Revoke MCP session trust" only ever cleared in-memory grants, never a persisted rule. McpPolicyEngine.revokePersistedPolicy(toolName) is the operator-facing undo for setToolPolicy's persistent scope: resets the tool to ASK and clears session trust for it in the same call, since policyFor checks session trust before a non-DENY configured rule - revoking only the persisted half would leave a tool that happened to hold both still silently auto-allowed. Returns whether the write actually succeeded, so a failed revoke is reported rather than the UI showing a change that never reached disk. McpPolicyManagerDialog lists every persisted rule (tool name, ALLOW/ DENY, a per-row "Reset to Ask") and shows an inline error for a row whose reset failed to save. Wired into the bottom bar next to the existing session-trust revoke, behind a button that only appears once a rule exists - nothing new to look at when there is nothing to revoke. Not exposed as an MCP tool: this is a human-only control reachable by clicking, never callable by an agent on its own behalf. New tests cover the exact validation the review asked for: a persisted ALLOW revoked from the API resets to ASK and survives a reload from disk; revoking also clears session trust so the next call asks again even when both grants were held; an unrelated tool's DENY rule is untouched; and a failed write is reported through the same fault channel setToolPolicy already uses, not silently swallowed. * fix(mcp): make policy revocation actually remove the rule, off the UI thread revokePersistedPolicy previously rewrote a revoked rule to ASK instead of removing it, so the bottom-bar count never dropped and the policy manager dialog kept listing "revoked" tools with a no-op Reset button; under a hardened defaultMutatingAction=DENY config the leftover ASK was also weaker than the operator's own default. Removing the key instead fixes all three. The revoke write also ran synchronously on the Compose UI thread; onRevoke is now a suspend callback launched from the dialog's own coroutine scope, with the actual engine call wrapped in withContext(Dispatchers.IO) at the call site. A DENY row's reset now needs a second tap to confirm, since it is the one control in this dialog that raises standing privilege rather than lowering it. Stale operator-facing copy pointing at hand-editing the policy file was updated to point at the in-app dialog instead, in both the approval dialog and AGENTS.md's governance section. Left as a documented limitation rather than fixed: a queued approval answered "Always Allow" after the same tool's rule was freshly revoked can still re-persist it, since the existing preserveDeny guard only protects against a newer DENY, not a newer revocation. Closing that needs a revocation generation/timestamp compared inside the policy engine's lock. * fix(mcp): invalidate queued approvals when a saved policy is reset --------- Co-authored-by: Shivang * feat(plugins): add health and recovery center (#454) * feat(plugins): add health and recovery center * fix(plugins): retain recovery feedback and use lifecycle delegate * style(plugins): satisfy health recovery quality limits * fix(plugins): report unavailable recovery and observe sandbox disables * style(plugins): keep health dialog within function limit * style(plugins): remove unused sandbox state helper * style(plugins): format sandbox state lookup chain * fix(plugins): route watchdog recovery through safe reload * fix(plugins): finish sandbox teardown before recovery reload * fix(plugins): complete recovery teardown after caller cancellation * fix(plugins): restore panels when pre-lock uninstall is cancelled * fix(plugins): propagate cancellation before destructive uninstall * fix: prevent disabled plugin sandbox restart * fix: satisfy sandbox recovery static checks * style: format sandbox restart recovery * fix: close sandbox restart admission races * test(plugins): pin disable races and clarify cancelled uninstall outcome * test(plugins): configure budget race fixture explicitly * style(plugins): format sandbox race regression fixture --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): #429 follow-ups: one offer filter, root-named failures, matching cancellation contracts (#483) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails t… * feat(plugins): route a missing-dependency prompt to the window that raised it (#465) * feat(plugins): route a missing-dependency prompt to the window that raised it MissingDependencyPrompt is delivered over a Channel: exactly one window's collector receives each prompt, but not necessarily the one whose install raised it. The class's own KDoc named this as a known, deliberately unbuilt limitation - "routing back to the reporting window would need the prompt to carry a window id and the collector to be able to decline one without consuming it... not built, because a single window is the overwhelmingly common case." This builds exactly that, for the case where it does happen: a person answers "Install" in the window they're looking at and nothing changes there, because a different, idle window happened to collect it. - MissingDependencyPrompt gains windowId: String? = null, resolved via WindowFocusManager.resolveActionableWindowId() at the two report call sites where a window is unambiguously the actionable one right now (MissingDependencyReporter.report, a user-initiated install-completion path; MissingPluginOffer.offerIfMissing, a direct button click) - the same best-effort "whichever window is actionable" idiom this codebase already uses for deep links and CLI commands, not a new standard. - shouldClaimMissingDependencyPrompt (PluginDependencyResolution.kt) is the pure decision a wrongly-chosen collector uses: claim it if there's no reporting window to prefer, if this window IS the reporting window, or if the reporting window has simply closed (a prompt must never wait forever for a window that's gone). Kept pure and tested the same way shouldShowMissingDependency already is. - The collector in BossAppEventBusEffects.kt calls this before showing a prompt; when it says no, the window re-offers the prompt to the bus (a short delay first, so two non-target windows can't trade it back and forth in a tight loop while the real target's own collector is still starting up) instead of displaying it. - WindowFocusManager.isWindowOpen and resolveActionableWindowId are promoted onto the expect/actual contract so commonMain code (where MissingDependencyPrompt and its bus already live) can ask both questions without a desktop-only reference. Scope: PerformanceState.performancePanelPrompt (the status-bar indicator's offer) is left at the windowId = null default - it resolves its plugin manager via DynamicPluginManager.anyActiveManager() with no direct window context available, and threading one through would mean unrelated refactoring of that call chain rather than this feature. Falls back to today's behavior for that one path only, same as every prompt did before this change. 5 new tests (MissingDependencyPromptRoutingTest) cover the decision function directly: no reporting window, own window, wrong window with the target still open, wrong window after the target has closed, and the single-window case. Full desktopTest suite, ktlintCheck and detekt all clean (one pre-existing, unrelated Windows-symlink-privilege test failure in DesktopFileScannerTest, confirmed present on unmodified main). * fix(plugins): return routed prompts before collector suspension * fix(plugins): replace the bounded channel with a lossless prompt registry Review of this PR (batch-4) proved two real bugs in the bounded-channel delivery PluginDependencyBus used: reporting one prompt, receiving it, admitting four unrelated ones, then re-reporting the first refused it - the channel's four-slot capacity had nowhere left to put it, and it was gone. Separately, freeing the dedup guard the instant a prompt was handed to any collector (rather than when a collector was actually done with it) let a second, independent report for the same key be admitted as a competing entry while the first was still in transit. Replaces the Channel(capacity=4) + separate dedup set with one LinkedHashMap, guarded by a single lock so admission (report) and claiming (claim) are each one atomic step. A map has no capacity to overflow, and a key stays reserved from report() until claim() actually removes it - there is no gap for a concurrent report to slip into. missingDependencies now broadcasts every pending prompt to every open window; each window decides independently (via the existing shouldClaimMissingDependencyPrompt) whether to claim(), and a losing racer simply does nothing further. This also removes the reoffer mechanism entirely: a window that is not the right audience for a prompt now just leaves it alone rather than report()-ing it back with a delay, which was the "unbounded routing hops" the review flagged (one wait-and-retry loop per rejected prompt per window). A single bus-owned periodic re-broadcast covers the one case no report() fires for: the preferred window closing with nothing else to wake collectors. New/rewritten tests reproduce the review's own adversarial scenario directly (a prompt survives being re-reported while four distinct prompts are also pending) and prove claim() is atomic under concurrent callers, alongside the existing dependency-resolution and pure-function routing tests, all updated for the claim-based consumption model. * test(plugins): fix a test broken by the claim-based bus consumption model PerformancePanelPromptTest's "forcing twice does not queue two dialogs" assumed the old design's receive-consumes semantics: a plain missingDependencies.first() no longer removes anything from the bus, only claim() does, so the test's second first() call was seeing the same still-pending prompt again instead of timing out. Missed in the original PluginDependencyBus rewrite because this test lives outside the components.plugin package the rest of the affected suite is in - caught by CI (ubuntu/macos/windows build-test all failed on it), not by the targeted test runs during that change. * fix(plugins): re-resolve a disposed manager's installer, fix a claim-timing hole Addresses the three findings from the claim-based-queue review round: - The closed-target fallback (shouldClaimMissingDependencyPrompt's !targetWindowOpen clause) lets a different window claim a prompt once its preferred window has closed - but the prompt's installer was still bound to that closed window's DynamicPluginManager, which disposeWindow() has already cancelled and uninstalled everything from. Install then loaded into a dead manager and reported success having put the plugin nowhere anything renders - the exact #188 symptom this repo's own release notes describe. installerFor now re-resolves to DynamicPluginManager.anyActiveManager() at use time for both the isInstalled check and the actual install, falling back to the originally-bound manager only when nothing at all is live - the same fix anyActiveManager's own KDoc already documents for the home screen's install tile, applied here too. - claim() ran before the isInstalled check's IO stat, so cancelling that coroutine mid-stat (window closing, the effect restarting) permanently lost an already-claimed prompt: it was off the bus with nowhere else recorded. Moved the claim to immediately before the state assignment, after present/show are both decided - a non-suspending sequence from there, so the only remaining window is the documented, accepted one where the dialog itself is already up. The cost is a duplicated isInstalled stat in the rare case two windows reach the check on one prompt at once, which claim() returning false for the loser already handles correctly. - The bus's wake signal (changed, a MutableSharedFlow(replay = 1)) had no extra buffer, so tryEmit returned false - discarded, since report() never checked it - whenever a collector hadn't yet consumed the replayed value, i.e. whenever any window was mid-dialog. That is the common case for a second missing dependency, not a rare one, so the periodic ticker (documented as covering only a closed target window) was doing the real signalling work for the routine case too. Nothing was ever lost - pending still held every prompt - but a second dependency could sit for up to a second before its rescan. extraBufferCapacity = 1 with DROP_OLDEST makes tryEmit always succeed; conflating repeated "go look" signals is exactly right for a rescan trigger. Also: renamed a test that implied claim() was proven atomic under real concurrency - runTest serialises both async bodies, so it never was, and the assertion it actually makes (a second claim of the same prompt fails) is what the new name says. Added a test for the ticker's periodic re-broadcast of a still-pending, unclaimed prompt, which had no coverage at all. MISSING_DEPENDENCY_RESCAN_INTERVAL_MS is internal now (from private) so that test - and any future one - can pin the real interval instead of a copy of it, matching this repo's own precedent for timing constants. Five doc sites (AGENTS.md and four KDoc cross-references) that still described the deleted channel/reoffer design are corrected to describe the current claim-based map registry instead. * fix(plugins): retire suppressed prompts and preserve live installer ownership --------- Co-authored-by: Shivang * fix(secrets): preserve hostnames in the host matching utility (#482) * fix(browser): catch native disposal exceptions to prevent incorrect sandbox disables Wraps native JxBrowser event listeners to prevent ObjectClosedException from escaping into the sandbox manager. Previously, unexpected browser disposal could be interpreted as a sandbox failure and consume the plugin restart budget. * fix(security): tighten browser secret domain matching * Wrap matcher regression tests to satisfy Detekt * Keep full hostnames visible in saved-secret display labels --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shivang * feat(settings): explain experimental Microkernel Mode before enabling it (#481) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names e… * fix(shortcuts): trigger key bindings on key release instead of key press (#492) * fix(shortcuts): trigger key bindings on key release instead of key press * fix(shortcuts): invoke a bound action on key-up, not key-down (#490) A shortcut's action ran from the KEY_PRESSED event, so holding a chord past the OS auto-repeat threshold fired it repeatedly (Cmd+N held down kept creating tabs), and the action always ran while the keys were still physically held. AWTKeyboardInterceptor.install() now recognizes a matching chord on KEY_PRESSED - claiming it (so it still doesn't leak to BossTerm/a browser while held) - but only arms it as a PendingShortcut. The bound action fires exactly once, from handleKeyReleased, when the primary key's own KEY_RELEASED arrives; a repeat KEY_PRESSED for the same key stays claimed without re-arming or re-firing. Releasing a modifier by itself can never match, since a modifier key is never allowed to arm in the first place. Focus moving to another window (or away entirely) clears any armed-but-unfired chord via a focusedWindow property listener, and uninstall() clears it too. dispatchAction gained a `perform` flag (default true, so every existing call site is unchanged) so handleKeyPressed can ask "would this dispatch" - reusing the exact same gate each action already had (canStepTabs, activePanelTabCount, panelCountState, ClosedTabHistory) - without performing the trigger, and handleKeyReleased later asks for real. This is one function answering both questions rather than two that could drift, at the cost of a narrow, documented race if the gate's answer changes in the few hundred ms between press and release. PluginShortcutRegistryImpl.dispatch has no such probe, so a matched plugin default arms optimistically and is confirmed for real at release. KeyboardShortcutInterceptor.kt (a second matcher against the same KeymapMatcher, not wired to any production caller today) gets the same arm-on-KeyDown/emit-on-matching-KeyUp treatment for consistency. New tests drive handleKeyPressed/handleKeyReleased directly with synthetic AWT KeyEvents (the same way the existing gate tests drive dispatchAction/dispatchIfCanStepTabs directly), covering: a key-up fires exactly once, a mismatched or modifier release does nothing, a repeat key-down neither re-arms nor re-fires, a gate closing between press and release is honored at release, and cancelling drops an armed chord. * fix: wokflows * fix(shortcuts): validate pending chord lifetime and release ownership * fix(shortcuts): fix MRU wedge, stale-arm swallow, and dropped-chord bugs (#490) Addresses the three substantive findings from review: - MRU tab cycling wedged open when the modifier released before the primary key. Arming (tabCycleActive/tabCycleModifierKeyCode) had moved to handleKeyReleased, where the cycling modifier is no longer guaranteed still down - the OS can deliver the two key-ups in either order, and releasing the modifier first meant arming for a release that had already happened. Extracted armTabCycleIfApplicable and call it from handleKeyPressed instead, where the modifier is guaranteed held (it's what made the chord match). - A lost KEY_RELEASED (swallowed by a native popup, a heavyweight surface, or an OS-level chord grab, with focus never moving so nothing else cleared it) left a stale armed entry that the repeat- claim check matched on keycode alone - so a later, unrelated bare press of the same physical key was silently swallowed, and its key-up fired the shortcut for a keystroke that was never meant to be one. PendingShortcut now records the arming event's four modifier flags, and the repeat-claim check requires them to still match; a mismatch falls through to normal re-matching instead, which also makes a stale entry self-heal on the next genuine press of that chord rather than staying stuck until focus changes. - Rolling one chord into another without fully releasing the first (Cmd+N into Cmd+T at speed) evicted it from the single pending slot, so its eventual release found nothing to fire and leaked to the focused component with no matching key-down to explain it. pendingShortcut is now pendingShortcuts, keyed by physical keycode, so concurrently-held chords on different keys get independent slots. Also: a release that matched and cleared an armed chord is now always consumed, regardless of whether the underlying dispatch reports handled - the key-down was already withheld from the focused component, so letting the key-up through alone on a refused gate or a plugin that unregistered mid-chord left a listener a key-up with no matching key-down to make sense of. tabCycleActive/tabCycleModifierKeyCode are now internal (from private) so a test can pin the arm-at-press behavior directly. KeyboardShortcutInterceptor.kt (still unwired to any production caller) gets the same stale-arm protection via onFocusChanged, for the "consistent key-up semantics" criterion the next person wiring it up will actually see. New/updated tests: armTabCycleIfApplicable's MRU-gating in isolation (arms for TAB_NEXT/TAB_PREVIOUS under MRU, does nothing otherwise or for an unrelated action), a structural proof that handleKeyReleased no longer arms the cycle itself, a stale-arm-with-different-modifiers regression, a two-independent-chords regression, and the gate-race test updated to assert consumption instead of the old handled-based return. 16/16 ShortcutKeyUpInvocationTest, all touched suites green. * fix(shortcuts): finish reviewed cancellation and headless regressions * refactor(shortcuts): resolve CI complexity findings * test(crash): tolerate transient Windows report read locks * test(crash): wait for drain marker deletion on Windows --------- Co-authored-by: Antriksh Co-authored-by: Shivang * review: drop repo-wide .gitattributes from the plugin CLI change The EOL policy change applies to every file and every contributor's checkout, not to the launchpad feature. It belongs in its own maintainer-decided change; keep this PR scoped to the plugin CLI. * fix(launchpad): only treat LOADED installs as confirmed reloads installPlugin returns Result.success with state DISABLED when registration fails as binary-incompatible or the plugin is hidden for lack of access, so an isSuccess check reported a confirmed reload for a plugin the host then force-unloaded. Check state == LOADED and log the real state. Also drop waitForGC=true: the 30-second System.gc() poll loop ran on Dispatchers.Main and could freeze the UI. Every other host reload path takes the false default and lets the old loader be reclaimed out of band. * fix(plugin-cli): truthful reload outcomes, validated ids, launcher exit codes - reloadDevPlugin: 45s client budget, 60s server budget for the verb, and a TimedOut result (host alive, reload unconfirmed) distinct from HostOffline, decided by a post-timeout probe ping. The CLI prints a staged_unconfirmed state instead of exit 0 for every outcome. - VERB_PLUGIN_DEV_RELOAD validates the plugin id on the wire (no separators, not ".."; otherwise REJECTED); the client pre-validates. - boss.bat: the ps1 branch swallowed exit codes because !ERRORLEVEL! stays literal under DisableDelayedExpansion and %ERRORLEVEL% parses stale inside a parenthesized block; the branch is now a label parsed after powershell returns. - PluginValidator closes its URLClassLoader (the jar handle stayed open per validation, notably on Windows). - Drop unreachable HostMeta.DEFAULT_API_VERSION; the manifest default uses the generated constant. Tests: wire rejection of traversal ids, TimedOut vs offline split, client-side id refusal. * fix(cli): enforce reverse-domain plugin ID, host manifest validation, and safe staging retention * fix(db): stop materializing inherited client routine access * fix(logging): mask a URL whose fragment comes before its query (#541) Preserve masked URLs when a fragment precedes the query. Original implementation and four regression tests by @arjun28115. Reviewed current-head Claude feedback and passing CI; existing adjacent nested-query behavior remains a documented follow-up. * test(config): isolate Chromium flags test from local environment variables (#507) * test(config): isolate Chromium flags test from local environment variables (cherry picked from commit c6bce0ded1a4157b84cb724577f89ff9a0cd3cc9) * test(config): pin the env-wins and blank-UNSET rules the envReader seam unlocks Addresses the Claude review of #507: - move the envReader seam declaration up beside settingsFile so the KDoc reattaches to envOverride and the seam is initialised before bootSettings = loadSync() (object initialiser declaration order); - previewValue test now exercises the environment outranking the setting and the blank-reads-as-UNSET guard, both untestable before the seam existed; - reword two comments that the seam made stale. * test(config): close out the second Claude review of #507 Addresses the re-review of 6bcd83c73 (comment text only, plus one hermeticity line in a test): - the init-order comment read inverted - it called an initialiser below the envReader declaration the hazard, when the hazard is an initialiser ABOVE it (which is exactly what the old layout had, with bootSettings declared above the seam); reworded; - 'envOverride reads the environment only' now stubs envReader to null for its own assertions, so it is hermetic by construction rather than by the BOSS_TEST_UNUSED_KEY naming convention. --------- Co-authored-by: Shivang * fix(search): route cross-window tab selections to target window (#500) * fix(search): route cross-window tab selections to target window * refactor(search): focus target window via WindowFocusManager, wire TabEventBus to kernel IPC * fix(search): honor closed target window, forward origin window in TabEventBus Addresses the Claude review of the cross-window tab selection change: - TabEventBus.selectTab now takes sourceWindowId (the dialog's own window) and forwards it as the IpcEventBridge envelope source, matching the origin contract the other twelve buses and EventBusServiceImpl rely on. - TabSelectEvent is @Serializable so kernel-mode forwarding emits structured JSON instead of the toString() fallback with a WARN. - onTabSelect honors WindowFocusManager.focusWindow's Boolean result: a closed target window now logs a warning instead of emitting into the void (the bus has no replay). - The 50 ms effect delay is named TAB_SELECT_FOCUS_GRACE_MS with a comment stating it is UX grace, not a correctness barrier. - TabSelectEventTest: the no-replay test proves live delivery first and only then asserts the late collector sees nothing; both tests use explicit subscription barriers instead of yield(); ipcBridge is reset in @AfterTest; KDoc states the tests pin the bus contract, not the shipped BossAppEventBusEffects filter. * docs(search): correct bus KDocs after destination-addressed TabEventBus Per Claude delta review at af7c292: - BossAppEventBusEffects file KDoc: note the TabEventBus exception to the window-filtered-by-sourceWindowId rule (Issues #498/#506). - TabEventBus KDoc: spell out the cross-process subscription consequence (subscribers must not set SubscribeRequest.sourceWindowId to their own window id; route on the payload's targetWindowId) and fix the 'this file's twelve sibling buses' wording. - TabSelectEventTest KDoc: the bus carries two properties, not three. --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux (#535) * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux - Problem: MenuShortcutBridge.getKeyShortcut() always set meta=true for Cmd bindings, which Compose Desktop maps to the Super/Windows key on Windows/Linux, so native menu items like Ctrl+N/Ctrl+, failed to fire. - Fix: resolve effectiveCtrl and effectiveMeta from SystemUtils.isMacOS before constructing KeyShortcut, matching the platform-aware mapping in KeymapMatcher and AWTKeyboardInterceptor. - Verification: ./gradlew :composeApp:desktopTest --tests "ai.rever.boss.keymap.ShortcutCaptureConsumersTest"; :composeApp:ktlintCheck; :composeApp:detekt. * fix(keymap): gate browser zoom/reload menu items, make mapping testable per-platform Addresses the Claude review of the menu accelerator change: - The View menu's Actual Size / Zoom In / Zoom Out / Reload items now carry enabled = hasBrowser like Back/Forward/Developer Tools. Their bindings are BROWSER-context and AWTKeyboardInterceptor declines them outside a browser tab, so before this the newly-live Ctrl accelerators on Windows/Linux could fire them from a terminal or editor tab. - The platform modifier mapping is extracted into the pure menuAcceleratorModifiers(hasCmd, hasCtrl, isMacOS), with the asymmetry comment moved with it. The test now pins both platform branches unconditionally on any runner; the bridge-level tests keep asserting the live SystemUtils wiring. - One-line comment keeping the legacy packed-keycode coverage attached to Key.N.keyCode.toString(). * docs(keymap): record the deliberate menu-gate asymmetry in three stale comments Per Claude delta review at 928d3b206: after gating the four View items with enabled = hasBrowser, three comments still described the pre-gate behaviour. - windowsWithActiveBrowser KDoc now enumerates all seven gated items. - activeBrowserWindows KDoc records that the gate is deliberately stricter than the dispatch target (sidebar-slot / background-split browsers lose Zoom and Reload from the View menu; the broader gate would swallow Ctrl+R and Cmd+[/Cmd+]). - The 'gate is stricter' test restates the asymmetry without the now-false claim that a sidebar browser still gets Zoom and Reload. --------- Co-authored-by: kshivang Co-authored-by: Shivang * fix: keep focus mode rail actions stable (#519) Keep focus-mode quick actions stationary in the collapsed rail while the drawer reveals beside it. Preserve rail interaction when the chevron dismissal catcher is present and keep heavyweight tooltips visible. Original implementation: Ayush Santosh Chhatre (@Ayush04-C). Consolidation source #534: Rohan Gaikwad (@rohangaikwad4), credited for the parallel stationary-rail implementation and permanent-activity-bar framing. Prior review-agent catcher and regression-test repairs remain separately attributed in the PR description and review record. Reviewed head 9e939539c8f971cd45e33aa336d261eb21d5a492: all nine CI checks pass, including three platform builds/tests and code quality. Claude's final import/KDoc findings were addressed; dev does not alter the PR's touched files. Manual visual checks remain recorded in the PR/review inventory. * fix(passkey): escape mobile WebAuthn HTML templates against reflected XSS (#546) Escape mobile WebAuthn template values in their HTML and inline JavaScript contexts to prevent reflected script injection. Original vulnerability analysis, contextual escapers and initial regression tests: Antriksh (@Antriksh1984). Prior agent follow-ups repair replacement-pattern expansion and add single-pass substitution plus additional regression coverage. Agent repairs are separate from the original submission score. Validation: all nine current-head CI checks pass; prior combined current-dev integration passed 167 Deno tests and 784 pgTAP assertions. Claude findings have been addressed with dispositions documented on PR #546. No production deployment is included. Co-authored-by: Antriksh * fix(project): stop the per-window recent-projects provider from leaking, without freezing KERNEL clients (#524) Stop window-owned recent-project collectors at disposal while keeping KERNEL watch streams connected to process-wide state. Original implementation by Antriksh; prior review integration and regression hardening are documented in the PR. Co-authored-by: Antriksh * Stop FileIcons keeping a second copy of the icon extension list (#525) * Stop FileIcons keeping a second copy of the icon extension list `FileIcons.forFile` gated `LanguageIcons.forExtension` behind a `when` whose 52 branches were byte-identical: each listed some extensions and then called `LanguageIcons.forExtension(extension).toFileIconInfo()`. The branches carried no behaviour, only a vocabulary, and that vocabulary is already declared in LanguageIcons' own `when` in the adjacent file. It is one of the duplicated language tables BossConsole#75 is about, and #358 did not reach it: that PR consolidated the host and out-of-process language-ID tables, while the icon layer kept its own. The copy had already drifted, which is the failure mode #75 describes. Eleven extensions LanguageIcons has icons for were missing from the gate and fell through to the generic file icon: astro, eslintignore, eslintrc, gql, graphql, pom, prettierignore, prettierrc, prisma, proto and styl. Nothing reported that; the file just rendered as a blank document while the icon sat one file away. Measured before changing anything: the gate routed 108 extensions and LanguageIcons knows all 108, so no extension loses an icon. The gate was a strict subset, so deleting it is additive. `forExtensionOrNull` is the nullable variant that lets a caller ask whether an extension is recognised without comparing against `unknown`, which a real mapping could legitimately return. `forExtension` keeps its total contract and delegates. The 19 explicit branches stay ahead of the delegation because they deliberately disagree with the language mapping: a `.xml` is a config document, `.bat`/`.cmd` take PowerShell's colour, `.env` is a lock, and `jar`/`war`/`ear` are archives carrying Java's colour rather than Java source. `forSpecialFileName` still runs first, which is why `pom.xml` is Maven rather than XML. FileIconsLanguageDelegationTest pins all of it: the fallback for a genuinely unknown extension, a spread of the previously gated extensions, each of the eleven that had drifted, every override that must keep winning, the jar case, and that `forExtension` is still total. 47 tests in the module, 0 failures. Module ktlint and detekt clean; the two existing detekt baseline entries were rekeyed to the renamed function rather than widened. Co-Authored-By: Claude Opus 5 (cherry picked from commit a1c298ef63aeea9c676c2f45022b87007d6258c2) * style(file-icons): fix stale section comments left by the delegation change The 'Shell scripts' comment described branches this change removed, and the empty slot under 'Databases' is where the removed sql branch sat. Comments only; no behavior change. (cherry picked from commit 86a031c6adad92f5b74b4e83dd98b142c3706880) * style(file-icons): drop the value-identical gradle branch and stale docs Addresses the Claude review of #525: - remove the explicit "gradle" branch from forFile: it was value-identical to LanguageIcons' own mapping, i.e. exactly the duplication this PR removes; gradle files now resolve through the forExtensionOrNull delegation, pinned with a non-special name (libs.gradle); - the build.gradle assertion tested forSpecialFileName, not the removed branch; the xml-vs-maven override is now pinned directly; - move the Apple property list comment back above the plist branch and drop the orphan blank line at the Windows batch comment; - delete the orphaned KDoc left by the forExtension rename; - prune the baseline entry for the png line that is a block body now. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(plugin-store): stop the update check mis-ordering versions it cannot parse (#539) * fix(plugin-store): stop the update check mis-ordering versions it cannot parse `PluginRepositoryManager.isNewerVersion` split a version on "." and dropped any segment that was not a bare integer. Dropping rather than zero-filling shifts every later segment into the wrong position, so the comparison was made against the wrong field. Two cases a user would notice: "1.0.0+build.7" parsed as [1, 7]. Compared against an installed [1, 0, 0] the 7 landed in the minor position and won, so the store offered an update to the version already installed, on every check. SemVer section 10 says build metadata is ignored for precedence. An installed "1.0-beta.5" parsed as [1, 5], so the 5 sat in the minor position and beat the 2 of a genuine "1.2.3" release. The real update was never offered at all. The codebase already has one answer for this. SemanticVersion describes itself as the sole version-comparison primitive for plugin update and floor checks, and PluginUpdateManager uses it. This was the third comparator and the only one that disagreed with it; across a small matrix of realistic version strings the two disagreed on 32 of 156 ordered pairs. plugin-dependency has no project dependencies of its own, so taking it as a dependency here cannot introduce a cycle, and plugin-updater already does it. One behaviour is preserved deliberately rather than inherited: a plugin whose installed version cannot be parsed is still offered an update. It is already in a broken state, and refusing every future update would strand it there. An unparseable candidate is never offered, because nothing can be said about it. Ten tests, of which five fail against the previous implementation. Co-Authored-By: Claude Opus 5 * docs(plugin-repository): correct version-shift comments and cross-reference the two comparators Addresses the Claude review of #539 (comments and one log line; no behaviour change): - the old code read '1.0.0+build.7' as [1, 0, 7] (the 7 in the PATCH slot, not the minor) and '2.0.0-rc.1' as [2, 0, 1] returning true on 1 > 0 at the third position; the test and KDoc comments now say so; - the test KDoc now names which cases are regression tests and which are guards - most also pass against the old comparison; - both isNewerVersion KDocs now cross-reference each other and state the deliberate fail-open vs fail-closed divergence on unparseable installed versions; - checkForUpdates documents that it has no production caller today (Toolbox path is PluginUpdateBridge -> PluginUpdateManager) and logs when a candidate is refused because its store version is not parseable, keeping 'no update offered' and 'we could not tell' distinguishable. * test(plugin-repository): close out the second Claude review of #539 Addresses the re-review of 20b7e6ff5: - the test KDoc listed six regression cases, but 'a release is newer than its own pre-release' passes against the old comparison too ([1,2,3] vs [1,2] decides at the third position both ways) - it is a guard, as the PR body already said; the list now names the five real regressions; - the checkForUpdates KDoc claimed the function is 'pinned by tests' while nothing called it; it is now driven end to end by two new tests (a newer store version is offered; an unparseable store version is never offered), which also cover the new offerUpdateIfNewer helper and its debug log; - the KDoc now states the honest reason the callerless function is kept: the repository layer should answer the same question the updater does if the paths are ever merged. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(downloads): stop a hostile file name throwing out of the download handler (#540) * fix(downloads): stop a hostile file name throwing out of the download handler FileNameSanitizer.sanitize runs on the name the download source chose, so its input is whatever a server puts in Content-Disposition. Two inputs got the wrong answer. A long extension threw. Nothing bounds how long an extension is: it is whatever follows the last dot. A name of "a." plus 300 characters gives an extension of 301, and subtracting that from the 255 limit gave a negative budget that reached take(), which rejects a negative count and throws out of the handler. The extension is now abandoned when it cannot fit, rather than shortened, because a truncated extension is not the file's type and inventing one is worse than having none. A name of only dots came back ending in a dot. For ".", ".." or "...", the extension computed in step 4 is a lone ".", and the "ensure at least one character" fallback appended it raw to produce "download." That re-created exactly the trailing dot step 5 removes, and a trailing dot is what is invalid on Windows. Truncation could re-expose one too, so the rule is re-applied to whatever the cut produces. The file had no tests. Eleven are added, four of which fail against the previous implementation. They also pin the behaviour that was already right, so the Windows device names, the traversal reduction and the control-character handling cannot regress silently. Verified against the local ktlint and detekt gates. Co-Authored-By: Claude Opus 5 * fix(downloads): abandon an oversized extension instead of truncating it Review follow-up on @arjun28115's fix: the documented intent is that an extension which cannot fit in the 255-character limit is abandoned rather than shortened (a truncated extension is not the file's type), but the budget <= 0 branch kept a prefix of the extension. Preserve the base name only, cut to the limit, and pin the behavior with a regression test. 12 tests, 0 failures; ktlint and detekt clean. * fix(downloads): recompute the cut's extension and re-defuse reserved names Review follow-up on @arjun28115's fix: - truncate() took the extension computed before the trailing-dot trim. For exactly the hostile shape this PR targets - a name ending in a dot - that copy is a stale lone '.', so a real '.pdf' that fit was dropped and the download landed extensionless. The extension is now recomputed from the string being cut, as the helper's KDoc always claimed. - The cut's trim can also shrink the base onto a reserved device name step 4 missed: 'CON ' (space before the dot) is not a reserved segment, so with an oversized extension the result was bare 'CON', which Windows refuses. The cut result is now defused like step 4. - The device-name test title overclaimed ('whatever their case or extension'); it now names what it covers, pins the defused shape, and includes the trailing-space input the description claimed was tested. 14 tests, 0 failures; ktlint and detekt clean. * fix(downloads): compare a device name at the first dot, as Win32 does The review of 9d04f35 is right and the disposition that closed finding 2 was not. A device name followed by an extension is still the device: Microsoft's naming rules say NUL.txt and NUL.tar.gz are both equivalent to NUL, and this file already agreed, since step 4 rewrites CON.txt to _CON.txt. The check compared the segment before the LAST dot, which leaves two holes that are the same underlying fact. Multi-dot names were missed entirely. "CON.tar.gz" was compared as "CON.tar", which is not reserved, so it passed through untouched. Truncation could manufacture a device out of a name that was not one. "CONSOLE." followed by 251 characters is not reserved at step 4. Cutting it to fit the limit left "CON." and the rest, which Windows resolves to the console, so the download writes nowhere and no file appears. The defuse added in 9d04f35 did not catch it, because it compared the whole cut rather than the base, and only the budget <= 0 branch could ever match. Both close by comparing the segment before the first dot, in one helper used by step 4 and by the cut. Trailing spaces are trimmed before the comparison for the same reason: Windows ignores them. Defusing prepends one character, which would push a full-length result to 256. The cut is therefore redone one character shorter when that happens, which is always enough because the prefix is exactly one character. Three tests, all of which fail against 9d04f35. The last asserts the invariant rather than a case: no input in the list comes back as something Windows would resolve to a device. The title of the existing device test said "with a single extension", which was accurate before this and is not now, so it has been corrected rather than left to mislead. 17 tests, ktlint and detekt clean. Co-Authored-By: Claude Opus 5 * test(downloads): pin the retry-that-defuses cut and the CONSOLE redo shape Closes the two test-only items from the third Claude review of 40700c137: - new test for the one path where a defused cut survives into the returned value: the 255 cut and the 254 redo both trim onto CON, both defuse, and the result lands exactly at the 255 limit; - the CONSOLE. + 251x test now pins the full redo shape (CO. + 251x) instead of a non-device check that would also accept a fallback name; - the padded-name test comment now says step 4, not the cut, defuses it; - truncate's comment states the cutTo limit + 1 bound that makes one redo always enough, and the step 4 extension local says it serves the step 6 fallback. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(browser): prevent memory leak by unifying browser teardown paths (#502) Unify external browser closure with idempotent full teardown. Includes reviewed EDT dispatch, unconditional unregister and regression guards. Original contribution: Anshul Pundhir (@pundhiranshul). Review-agent hardening is documented in PR #502. Co-authored-by: pundhiranshul * fix(runner): honor main-panel rerun delay and document unsupported settings (#486) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and name… * fix(repository): a cancelled store call is not a network failure (#531) * fix(repository): a cancelled store call is not a network failure; apply downloadPlugin's rule to every store call * test(repository): pin propagation and the ratePlugin path; fix stale call-site comments Claude review follow-up on #531, on top of aryanbajaj868's fix: - RemotePluginRepositoryCancellationTest: ratePlugin is driven by the held server too (it refuses before a request without an access token; a.b.c is a structurally valid three-part token with no admin claim), and every store call is now asserted to propagate the caller's CancellationException out of the method, not merely to stay out of the log - a swallowed cancellation is also no ERROR line, so the log assertion alone cannot see that half. - onStoreFailure moved into the Helper Functions section; the test server's single dispatch thread is documented. - Three call-site comments (StoreMissingDependencyInstaller, PluginStoreVersionBridge, PluginLoadGateRecovery) restated their rationale: the bundled repository now rethrows a caller's cancellation, but the PluginRepository interface permits either shape, so the flattened handling and the rethrow guards stay. --------- Co-authored-by: Shivang * Restrict completed_authentications to the service-role Edge Function (#536) * Restrict completed_authentications to the service-role Edge Function completed_authentications holds the freshly minted access_token and refresh_token for the cross-device (QR) login handoff. Its anon policies were each guarded only by `session_id IS NOT NULL`, and session_id is NOT NULL on every real row, so all three were `USING (true)` written the long way. Nothing required the caller to know a session id, so the "unguessable UUID acts as a bearer token" model the surrounding comments describe was never enforced. With `GRANT ALL ON TABLE ... TO anon` and an anon key that is public by construction (it ships in the desktop app and this repo is open source), that was reachable by anyone who can reach PostgREST: 1. Token harvest. Selecting the table returns every in-flight login's access and refresh token. Polling faster than the legitimate desktop wins the race to any QR-logging-in user's session, which is account takeover. 2. Denial of service. A single DELETE wipes all in-flight logins. 3. Session fixation. anon INSERT pre-seeds a victim's session_id with attacker-controlled tokens, which the victim's desktop imports as its own. Reported as BossConsole#528 by @Rushikeshiitb, including the fix shape used here. Safe because no client reaches the table directly any more, which I verified rather than took on trust: grepping the desktop source for completed_authentications returns nothing outside build output, and the desktop polls GET /passkey/auth/status/{sessionId} instead (SupabaseApiClient.kt:103). Every read and write happens inside the passkey Edge Function, whose Supabase client is built with SUPABASE_SERVICE_ROLE_KEY (passkey/index.ts:36). service_role bypasses RLS, so the anon and authenticated policies and grants are dead code on the live path. The authenticated policies go with the anon ones because the flow does not use them either, and leaving a role with standing access to a table of bearer tokens is the condition that made this exploitable. Follows 20260910120000_restrict_plugin_downloads_rls.sql: revoke the base-table privileges as well so the gate does not rest on RLS alone, including from PUBLIC, since a grant there would survive revoking the two named roles. The six policy names were checked against 20251023000013_rls_policies.sql programmatically. A mistyped name in DROP POLICY IF EXISTS is a silent no-op that would leave the hole open, and the pgTAP test asserts absence rather than trusting the drop. Co-Authored-By: Claude Opus 5 * test(db): carry over service_role privilege assertions from #530 BossConsole#530 by @Rushikeshiitb asserted that service_role keeps every verb on completed_authentications after the lockdown; this suite only checked the surviving policy. Merge the two companion submissions by adopting those four assertions (plan 11 -> 15) so a too-aggressive revoke of the service-role grant fails the pgTAP suite. * fix(db): revoke client EXECUTE on the expired-row cleanup RPC cleanup_expired_completed_authentications() is granted to anon and authenticated at 20251023000014_grants.sql:240-241, but no client code calls it: it returns void, only deletes rows past expires_at_timestamp, and after the table revoke it cannot execute its DELETE as a client anyway. Revoke the client EXECUTE so no client role keeps any handle to the token table, and pin it with three pgTAP assertions (plan 15 -> 18). Answering the open question raised in the PR description. * fix(db): also revoke PUBLIC EXECUTE on the cleanup RPC PostgreSQL grants EXECUTE on functions to PUBLIC by default, so the previous REVOKE ... FROM anon, authenticated left =X/postgres in the function ACL and has_function_privilege kept returning true for both client roles (pgTAP tests 16-17 failed on the previous head). Revoke from PUBLIC as well, matching the table revoke; service_role keeps its explicit grant (verified f|f|t in a scratch PostgreSQL 17). * test(db): pin the policy set and the verbs a named REVOKE leaves behind Claude's review of this PR found the two per-role policy checks would pass a policy created without a TO clause (pg_policies records it as roles = {public}, so it applies to everyone yet matches neither client role), that TRUNCATE - the one verb row level security cannot stop - was unasserted, and that authenticated was missing its UPDATE assertion. - Assert the full policy set is exactly the service_role one instead of checking each client role separately; verified on scratch PostgreSQL 17 that an unscoped policy fails the new assertion and passes the old one. - Assert TRUNCATE, REFERENCES and TRIGGER for both client roles, matching the precedent suite plugin_downloads_access_test.sql. - Add the authenticated UPDATE assertion, so both client roles are now covered per verb as the header promised. - Reword the cleanup-RPC comment to lead with the REVOKE EXECUTE as the actual gate and flag the stale SECURITY DEFINER header comment on cleanup_expired_completed_authentications (20251023000003_passkey_functions.sql:221) that made the fallback argument fragile. plan 18 -> 24. Verified all 24 assertions on a scratch PostgreSQL 17 replica of the post-migration state. --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang Co-authored-by: Shivang * fix(security): stop two audit logs accepting forged rows from any client (#538) * fix(security): stop two audit logs accepting forged rows from any client `secret_access_log` and `plugin_api_key_logs` are the audit trails for secret access and for plugin-store API key usage. Both had an INSERT policy of `WITH CHECK (true)` with no `TO` clause, and a policy with no `TO` clause applies to PUBLIC, which includes anon and authenticated. Both tables are writable by those roles. secret_access_log has an explicit `GRANT ALL ... TO anon, authenticated`. plugin_api_key_logs has no explicit grant, but 20251023000014_grants.sql sets `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon, authenticated` and that table is created later, so it inherits it. The anon key ships in the desktop client, so anybody could append arbitrary rows to either log. secret_access_log.user_id was unconstrained, so a row claiming another user viewed or shared a secret could be written at will, and the table's own SELECT policy then shows it to the named user and to every admin as though it were real. A genuine access event could equally be buried under forged entries, which is the failure an audit log exists to prevent. Neither policy comment described this. The plugin_api_key_logs policy is named "Service role can insert API key logs", which is what it was meant to be and not what it did. No legitimate writer is affected. Every function that writes secret_access_log inserts auth.uid() as user_id, which the new predicate permits. plugin_api_key_logs is written only by log_api_key_action(), which is SECURITY DEFINER and granted to service_role alone, so it bypasses RLS and needs no INSERT policy at all. Nineteen pgTAP assertions cover the fix, with privileges checked per verb rather than with a single ALL check, because has_table_privilege(..., 'ALL') is true only when every verb is held and one re-granted verb would pass an ALL-shaped assertion while leaving the hole open. Co-Authored-By: Claude Opus 5 * fix(db): close the definer-RPC door Claude's review opened Claude review found the plugin_api_key_logs half did not close the hole: log_api_key_action() is SECURITY DEFINER owned by postgres, and clients still held EXECUTE on it (PostgreSQL grants EXECUTE to PUBLIC by default, plus the schema-wide default-privilege grants inherited by this later-created function; the explicit service_role grant is additive). Any client could forge API-key history through the RPC even with the table locked. - Revoke EXECUTE on log_api_key_action() from PUBLIC, anon and authenticated; service_role (the only caller, via the plugin-store Edge Function's service-role client) keeps it. - secret_access_log: revoke authenticated INSERT too - every writer is SECURITY DEFINER owned by postgres and bypasses RLS without client table privilege, so a scoped client INSERT could only forge self-attributed rows. Drop the INSERT policy instead of recreating it scoped, since no client statement can insert anyway. - Tests: assert the RPC EXECUTE rejections/retention (3), flip the authenticated INSERT assertion, replace the three policy-shape assertions with an absence check (plan 19 -> 20). * fix(security): close the second RPC and the verbs a named REVOKE leaves behind Builds on kshivang's 2328a13, which closed the definer-RPC door the review found. Three things the review named are still open after it. update_api_key_last_used() has the same shape as log_api_key_action(): same file, SECURITY DEFINER, EXECUTE never revoked, service_role grant additive. A client can still stamp last_used_at on any key id, which corrupts the same audit story from the other end and makes a dormant key look live. Revoked, with the same three assertions the other RPC now has. Revoking named verbs leaves the rest of the original GRANT ALL: TRUNCATE, REFERENCES and TRIGGER. TRUNCATE is the one that matters, because it is not subject to row level security at all, so no policy can be its backstop. anon already lost these to the REVOKE ALL; authenticated did not. The per-verb argument in the test header was applied to one table. For plugin_api_key_logs only anon INSERT, authenticated INSERT and authenticated SELECT were asserted, so a re-grant of anon SELECT, UPDATE or DELETE, or of authenticated UPDATE or DELETE, failed nothing. That is the precise failure the header set out to prevent. The API key log policy's command is pinned too: a policy recreated as FOR ALL under the same name and role would otherwise pass every other assertion while granting far more. Six assertions are behavioural rather than catalogue-shaped, following plugin_downloads_access_test.sql: set the role, attempt the write, assert 42501. Each is one of the three defects in the migration header, tested directly rather than inferred from a privilege bit. Plan 20 to 39. I still cannot run supabase test db locally: no Supabase CLI, psql or Docker on this machine. CI's Database job remains the real check. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang * refactor(startup): split monolithic main.kt into modular startup phases (#450) * fix(microkernel): resolve startup failures and stabilize service lifecycle * fix: CI workflows * refactor(startup): split monolithic main.kt into modular startup phases * fix: CI and workflows * fix(startup): preserve failure notices and safe descendant teardown * test(startup): pin forwarding and shutdown extraction contracts * fix(kernel): report a restart cap once per failed generation * style(startup): format extraction regression tests * fix(startup): keep restored comments scoped to window composition * fix(startup): separate cancellation from shutdown timeout * fix(startup): acquire instance ownership before starting kernel * fix(startup): release ownership on construction failure and retry partial natives * fix(microkernel): retain repair notices and await forced process exit * test(startup): simplify interrupted native stream fixture * fix(startup): publish extracted native files atomically * fix(startup): coalesce retained diagnostics and normalize saved mode writes * fix(startup): unify effective mode gates and bound operator diagnostics * fix(settings): retain pending process mode and report save failures * fix(settings): synchronize pending mode across windows and explain unavailable builds * fix(settings): let shared preference refresh own displayed mode * fix(settings): use themed save errors and strengthen availability coverage * fix(build): restore store synchronization imports after dev integration * fix(settings): show running readiness and load mode support asynchronously * fix: failed menu save in BossWindow * fix(startup): integrate current dev, restore merge-dropped startup wiring The prior dev merge reintroduced closed #419 content on top of current dev, leaving duplicate single-instance blocks, a parallel BOSS_MODE preference writer, and dropped shutdown-registration wiring. - Remove the duplicate single-instance block in main.kt; keep the CliBootstrap.forwardToExistingInstance path - Restore the Phase 5 shutdown-hook registration (ShutdownSequence.register { kernelBootstrap } + lock-acquired log) that the merge dropped - Drop the branch's parallel EnvVars/ConfigLoader mode writers in favor of dev's canonical MicrokernelModePreference (#481); AdvancedSettings is byte-identical to dev - Route CLI deep-link forwarding through dev's forwardDeepLinkWithRetry (action links: 1 attempt; others: 3) without short-circuiting a multi-file selection after one failure - Restore awaitForcedExit waits + takeUnless{isAlive} unregister guards in OutOfProcessPluginSpawnerImpl cleanup/terminate (load-bearing for reaping; dev keeps the window-scoped hashed processId) - Remove dead microkernelMenuLabel; update CliBootstrap/EnvVars tests and AGENTS.md BOSS_MODE section to match dev's model * fix(startup): close the env_vars reader split and review follow-ups Env vars: - One shared normalization (EnvVarsFormat, commonMain) for the env_vars file: both in-repo readers (ConfigLoader's BOSS_MODE tier and MicrokernelModePreference's toggle reader) now parse identical line semantics, so Settings and the runtime gate can no longer state opposite modes for one file. export KEY=value is now recognized by both (shell convention; env_vars doubles as the secret-manager plugin's key file), and an indented '# BOSS_MODE=KERNEL' is a comment in both readers. - ConfigLoader.loadEnvVars widens its catch to Throwable: an ExceptionInInitializerError or NoClassDefFoundError from BossDirectories' own static init can no longer escape ConfigLoader's and poison every later getConfig call. - Restored dev's per-attempt 'Open request forwarding completed' INFO log in CliBootstrap (the extraction had left only the failure WARN). - A fully successful KERNEL spawn is logged, not toasted: only missing JARs or failed spawns report to the startup notice queue (previously every healthy launch showed a 12s developer-worded toast). Docs: - AGENTS.md: the env_vars priority paragraph and the microkernel toggle section no longer contradict each other - the toggle now activates the next launch via the env_vars tier, applied on restart, with the latched startup snapshot still as the 'restart required' comparand. - startupEnabledLatched KDoc updated to the post-env-vars-tier reality. Tests: - EnvVarsTest: export-prefixed and indented-comment line pins. - MicrokernelModePreferenceTest: indented-comment reads as disabled; new test asserts the settings reader and ConfigLoader reader agree line by line across the sample matrix. - ServiceStartupSummaryTest: the notice gate (no toast on a clean spawn, toast on missing/failed services). --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges (#505) * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges BossConsole#53 built a ServerInterceptor (ProcessIdentityInterceptor / ProcessTokenRegistry) that verifies which process is calling the kernel's out-of-process IPC surface, independent of anything a request body claims - but only wired it into PluginUIServiceBridge. The other fourteen service bridges, documented in that interceptor's own KDoc as "none of them authenticated today", still trust every caller unconditionally. SecretServiceBridge and RoleManagementServiceBridge are the two where that actually matters: the whole password vault (list/search/create/update/ delete/share/unshare) and RBAC (roles, permissions, grants) had no caller attribution at all. Any out-of-process plugin that can reach the kernel's local IPC socket could read, mutate or share every secret, or mint and revoke permission grants, with nothing to tell one plugin's calls from another's. Both bridges now require AUTHENTICATED_PROCESS_ID on every RPC - reads included, not only mutations, since an unattributed read of the vault list is exactly the exposure this closes - and refuse with PERMISSION_DENIED before the request ever reaches the underlying provider when there is none. Every mutating call also logs the verified caller identity, so a compromised or rogue plugin's activity is attributable after the fact. Every legitimate out-of-process plugin already carries a valid credential in production (ChildProcessBootstrap attaches ProcessTokenClientInterceptor whenever BOSS_PROCESS_TOKEN is set, which ProcessSpawner sets for every child it spawns), so this closes the gap without changing behavior for any real plugin. Tests exercise both bridges over a real gRPC server and real client, mirroring PluginUIServiceBridgeTest's own harness: an authenticated caller succeeds, an anonymous one is refused, and - the assertion that actually matters - the underlying provider is never invoked on a refusal. * test(kernel): pin the identity gate on late-registered bridges and close the updateSecret gap - SecretServiceBridgeTest: updateSecret gets its own anonymous-refusal test, matching the vault mutations it already covered. - SecretServiceBridgeTest: a service registered on an already-started server (BossIpcServer's late-service fallback registry, as KernelBootstrap.registerPluginServices does) is still gated by ProcessIdentityInterceptor - anonymous calls are refused before the vault is reached, token calls go through. This pins the registration order (fallbackHandlerRegistry before intercept) the production path relies on. - KDoc on both authenticatedCallerOrRefuse helpers: the AUTHENTICATED_PROCESS_ID snapshot is correct for unary RPCs; a streaming bridge must read CURRENT_IDENTITY instead (see PluginUIServiceBridge.streamUI). * test(kernel): tighten RBAC/secret refusal assertions, add revoked-token coverage, fix stale KDocs RoleManagementServiceBridgeTest: getRolePermissions, validateRoleName and validatePermissionName were asserted only as "some StatusException", with no counters on FakeRoleManagementProvider at all - a bare assertFailsWith would pass just as happily on UNAVAILABLE or UNIMPLEMENTED, contradicting the class's own KDoc claim that every refusal test proves the provider was never reached. Added the three missing counters and now assert PERMISSION_DENIED plus a zero counter on every refusal in both bridge test files, not just the two "an anonymous caller is refused" tests. SecretServiceBridgeTest: the late-registration test called tokenRegistry.issue(DEFAULT_PROCESS) for the same id setUp already issued for - issue() replaces and invalidates the previous token by design, so this silently killed the credential the shared `authenticated` stub carries for the rest of the class. Now issues a distinct id. lateServer teardown moved into a finally so a failed assertion doesn't leak a bound server. Moved an orphaned KDoc comment back onto FakeSecretDataProvider (SingleServiceRegistry's insertion had left it dangling above the wrong class). Added a revoked-token test to both files: identityFor returns null for a token the registry no longer holds, the post-reapChildren/post-respawn case, previously untested - every existing refusal test only covered "no credential at all", not "a credential that was live and got pulled". Two stale KDoc facts fixed in boss-ipc: ProcessIdentityInterceptor's comment named PluginUIServiceBridge as the only bridge checking identity (now three of fifteen, with SecretServiceBridge and RoleManagementServiceBridge named); BossIpcServer's tokenRegistry KDoc claimed a null registry "leaves every service's behaviour exactly as it was", which stopped being true for a server hosting either of the two fail-closed bridges - a null registry there means every call refused, not merely unauthenticated as before. * docs(ipc): correct the unauthenticated bridge count in the interceptor KDoc The kernel registers 16 bridges on its ipcServer (15 in KernelBootstrap.registerPluginServices plus PluginUIServiceBridge at KernelBootstrap.kt:475); three authenticate, so thirteen are unauthenticated, not twelve. Per the Claude review's one-word count fix. --------- Co-authored-by: kshivang * fix(cli): multi-window atomic reload, safe dev retention, and schema alignment - DevPluginReloader: implement multi-window preflight check ensuring all live managers can unload before executing any uninstall; enforce LOADED state verification on install; host-side staging retention strictly protects JAR paths actively referenced across any live manager - BossPluginCommand: prune staging history only on ReloadResult.HostOffline; rely on host-side retention when host is online - DefaultPlugin & PluginStoreSetup: swap startup candidates to dev JARs pre-load; remove runtime prioritizeDevPluginIfNecessary; prevent dev JARs from polluting or overwriting installed.json - PluginBuildProbe: skip hooks.record for dev JARs to avoid dev paths in installed.json - PluginManifest & PluginScaffolder: adopt requiredPermissions as primary property matching host schema with legacy permissions alias - PluginValidator: reject archives containing root plugin.json requiring META-INF/boss-plugin/plugin.json; eagerly cache entrypoint class bytes in ArchiveData to eliminate closed-ZIP ZipException in bytecode fallback - Tests: add DevPluginReloaderTest covering multi-manager atomic preflight and 4-stage retention preservation across multiple managers; update PluginValidatorEvalTest and PluginManifestTest * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and nam… * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog (#469) * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog McpPolicyEngine.setToolPolicy already writes a durable ALLOW/ASK/DENY rule to ~/.boss/mcp-tool-policy.json and takes effect immediately - it was fully built and tested, but the only caller was this repo's own test suite. AGENTS.md's own governance section names the gap plainly: "Persistent rules currently require editing ~/.boss/mcp-tool-policy.json and restarting." The interactive approval dialog for ASK-governed tools offered only "Approve Once" and "Always this Session" (in-memory, cleared on restart) - there was no way to tell BOSS "never ask about this tool again" without hand-editing JSON. - McpApprovalDecision.Approved/Denied gain a persistPolicy flag, threaded through McpApprovalBus.approve/deny (both additive, defaulting to false - every existing call site is unaffected). - McpToolRegistryCore.authorizeInvocation calls policyEngine.setToolPolicy(toolName, ALLOW/DENY) when persistPolicy is set, recording two new McpApprovalDisposition values (PERSISTENTLY_ALLOWED / PERSISTENTLY_DENIED) in the audit ledger so it's visible which of the three scopes - once, session, persistent - an operator actually chose. A persisted ALLOW makes the existing session-trust call redundant (policyFor checks the configured rule first), so the registry does not also call trustForSession when this is set - one less write, same result. - McpApprovalDialog gains "Always Allow" / "Always Deny" actions, visually secondary to the three existing per-call actions since they are the more consequential, less common choice. Scope: this is the interactive path only. The policy file itself, its fail-closed-on-unreadable behavior, and the kill-switch/RBAC layers upstream of policy resolution are untouched. 8 tests in McpGovernedInvocationTest (2 new) cover persisted ALLOW and DENY end to end: the approval/denial itself, the ledger disposition, and that a second call to the same tool no longer suspends for approval - confirming the rule actually took effect, not just that it was written. ktlintCheck, detekt and the full mcp/app/dialogs test packages all green. * fix(mcp): verify durable approval writes and preserve revocations * feat(mcp): let an operator inspect and revoke a persisted tool policy The review's remaining finding: after "Always Allow" persists a rule, there was no way back except hand-editing ~/.boss/mcp-tool-policy.json and restarting - the new caption and AGENTS.md text explained that, but disclosure is not a reversal control, and "Revoke MCP session trust" only ever cleared in-memory grants, never a persisted rule. McpPolicyEngine.revokePersistedPolicy(toolName) is the operator-facing undo for setToolPolicy's persistent scope: resets the tool to ASK and clears session trust for it in the same call, since policyFor checks session trust before a non-DENY configured rule - revoking only the persisted half would leave a tool that happened to hold both still silently auto-allowed. Returns whether the write actually succeeded, so a failed revoke is reported rather than the UI showing a change that never reached disk. McpPolicyManagerDialog lists every persisted rule (tool name, ALLOW/ DENY, a per-row "Reset to Ask") and shows an inline error for a row whose reset failed to save. Wired into the bottom bar next to the existing session-trust revoke, behind a button that only appears once a rule exists - nothing new to look at when there is nothing to revoke. Not exposed as an MCP tool: this is a human-only control reachable by clicking, never callable by an agent on its own behalf. New tests cover the exact validation the review asked for: a persisted ALLOW revoked from the API resets to ASK and survives a reload from disk; revoking also clears session trust so the next call asks again even when both grants were held; an unrelated tool's DENY rule is untouched; and a failed write is reported through the same fault channel setToolPolicy already uses, not silently swallowed. * fix(mcp): make policy revocation actually remove the rule, off the UI thread revokePersistedPolicy previously rewrote a revoked rule to ASK instead of removing it, so the bottom-bar count never dropped and the policy manager dialog kept listing "revoked" tools with a no-op Reset button; under a hardened defaultMutatingAction=DENY config the leftover ASK was also weaker than the operator's own default. Removing the key instead fixes all three. The revoke write also ran synchronously on the Compose UI thread; onRevoke is now a suspend callback launched from the dialog's own coroutine scope, with the actual engine call wrapped in withContext(Dispatchers.IO) at the call site. A DENY row's reset now needs a second tap to confirm, since it is the one control in this dialog that raises standing privilege rather than lowering it. Stale operator-facing copy pointing at hand-editing the policy file was updated to point at the in-app dialog instead, in both the approval dialog and AGENTS.md's governance section. Left as a documented limitation rather than fixed: a queued approval answered "Always Allow" after the same tool's rule was freshly revoked can still re-persist it, since the existing preserveDeny guard only protects against a newer DENY, not a newer revocation. Closing that needs a revocation generation/timestamp compared inside the policy engine's lock. * fix(mcp): invalidate queued approvals when a saved policy is reset --------- Co-authored-by: Shivang * feat(plugins): add health and recovery center (#454) * feat(plugins): add health and recovery center * fix(plugins): retain recovery feedback and use lifecycle delegate * style(plugins): satisfy health recovery quality limits * fix(plugins): report unavailable recovery and observe sandbox disables * style(plugins): keep health dialog within function limit * style(plugins): remove unused sandbox state helper * style(plugins): format sandbox state lookup chain * fix(plugins): route watchdog recovery through safe reload * fix(plugins): finish sandbox teardown before recovery reload * fix(plugins): complete recovery teardown after caller cancellation * fix(plugins): restore panels when pre-lock uninstall is cancelled * fix(plugins): propagate cancellation before destructive uninstall * fix: prevent disabled plugin sandbox restart * fix: satisfy sandbox recovery static checks * style: format sandbox restart recovery * fix: close sandbox restart admission races * test(plugins): pin disable races and clarify cancelled uninstall outcome * test(plugins): configure budget race fixture explicitly * style(plugins): format sandbox race regression fixture --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): #429 follow-ups: one offer filter, root-named failures, matching cancellation contracts (#483) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails t… * feat(plugins): route a missing-dependency prompt to the window that raised it (#465) * feat(plugins): route a missing-dependency prompt to the window that raised it MissingDependencyPrompt is delivered over a Channel: exactly one window's collector receives each prompt, but not necessarily the one whose install raised it. The class's own KDoc named this as a known, deliberately unbuilt limitation - "routing back to the reporting window would need the prompt to carry a window id and the collector to be able to decline one without consuming it... not built, because a single window is the overwhelmingly common case." This builds exactly that, for the case where it does happen: a person answers "Install" in the window they're looking at and nothing changes there, because a different, idle window happened to collect it. - MissingDependencyPrompt gains windowId: String? = null, resolved via WindowFocusManager.resolveActionableWindowId() at the two report call sites where a window is unambiguously the actionable one right now (MissingDependencyReporter.report, a user-initiated install-completion path; MissingPluginOffer.offerIfMissing, a direct button click) - the same best-effort "whichever window is actionable" idiom this codebase already uses for deep links and CLI commands, not a new standard. - shouldClaimMissingDependencyPrompt (PluginDependencyResolution.kt) is the pure decision a wrongly-chosen collector uses: claim it if there's no reporting window to prefer, if this window IS the reporting window, or if the reporting window has simply closed (a prompt must never wait forever for a window that's gone). Kept pure and tested the same way shouldShowMissingDependency already is. - The collector in BossAppEventBusEffects.kt calls this before showing a prompt; when it says no, the window re-offers the prompt to the bus (a short delay first, so two non-target windows can't trade it back and forth in a tight loop while the real target's own collector is still starting up) instead of displaying it. - WindowFocusManager.isWindowOpen and resolveActionableWindowId are promoted onto the expect/actual contract so commonMain code (where MissingDependencyPrompt and its bus already live) can ask both questions without a desktop-only reference. Scope: PerformanceState.performancePanelPrompt (the status-bar indicator's offer) is left at the windowId = null default - it resolves its plugin manager via DynamicPluginManager.anyActiveManager() with no direct window context available, and threading one through would mean unrelated refactoring of that call chain rather than this feature. Falls back to today's behavior for that one path only, same as every prompt did before this change. 5 new tests (MissingDependencyPromptRoutingTest) cover the decision function directly: no reporting window, own window, wrong window with the target still open, wrong window after the target has closed, and the single-window case. Full desktopTest suite, ktlintCheck and detekt all clean (one pre-existing, unrelated Windows-symlink-privilege test failure in DesktopFileScannerTest, confirmed present on unmodified main). * fix(plugins): return routed prompts before collector suspension * fix(plugins): replace the bounded channel with a lossless prompt registry Review of this PR (batch-4) proved two real bugs in the bounded-channel delivery PluginDependencyBus used: reporting one prompt, receiving it, admitting four unrelated ones, then re-reporting the first refused it - the channel's four-slot capacity had nowhere left to put it, and it was gone. Separately, freeing the dedup guard the instant a prompt was handed to any collector (rather than when a collector was actually done with it) let a second, independent report for the same key be admitted as a competing entry while the first was still in transit. Replaces the Channel(capacity=4) + separate dedup set with one LinkedHashMap, guarded by a single lock so admission (report) and claiming (claim) are each one atomic step. A map has no capacity to overflow, and a key stays reserved from report() until claim() actually removes it - there is no gap for a concurrent report to slip into. missingDependencies now broadcasts every pending prompt to every open window; each window decides independently (via the existing shouldClaimMissingDependencyPrompt) whether to claim(), and a losing racer simply does nothing further. This also removes the reoffer mechanism entirely: a window that is not the right audience for a prompt now just leaves it alone rather than report()-ing it back with a delay, which was the "unbounded routing hops" the review flagged (one wait-and-retry loop per rejected prompt per window). A single bus-owned periodic re-broadcast covers the one case no report() fires for: the preferred window closing with nothing else to wake collectors. New/rewritten tests reproduce the review's own adversarial scenario directly (a prompt survives being re-reported while four distinct prompts are also pending) and prove claim() is atomic under concurrent callers, alongside the existing dependency-resolution and pure-function routing tests, all updated for the claim-based consumption model. * test(plugins): fix a test broken by the claim-based bus consumption model PerformancePanelPromptTest's "forcing twice does not queue two dialogs" assumed the old design's receive-consumes semantics: a plain missingDependencies.first() no longer removes anything from the bus, only claim() does, so the test's second first() call was seeing the same still-pending prompt again instead of timing out. Missed in the original PluginDependencyBus rewrite because this test lives outside the components.plugin package the rest of the affected suite is in - caught by CI (ubuntu/macos/windows build-test all failed on it), not by the targeted test runs during that change. * fix(plugins): re-resolve a disposed manager's installer, fix a claim-timing hole Addresses the three findings from the claim-based-queue review round: - The closed-target fallback (shouldClaimMissingDependencyPrompt's !targetWindowOpen clause) lets a different window claim a prompt once its preferred window has closed - but the prompt's installer was still bound to that closed window's DynamicPluginManager, which disposeWindow() has already cancelled and uninstalled everything from. Install then loaded into a dead manager and reported success having put the plugin nowhere anything renders - the exact #188 symptom this repo's own release notes describe. installerFor now re-resolves to DynamicPluginManager.anyActiveManager() at use time for both the isInstalled check and the actual install, falling back to the originally-bound manager only when nothing at all is live - the same fix anyActiveManager's own KDoc already documents for the home screen's install tile, applied here too. - claim() ran before the isInstalled check's IO stat, so cancelling that coroutine mid-stat (window closing, the effect restarting) permanently lost an already-claimed prompt: it was off the bus with nowhere else recorded. Moved the claim to immediately before the state assignment, after present/show are both decided - a non-suspending sequence from there, so the only remaining window is the documented, accepted one where the dialog itself is already up. The cost is a duplicated isInstalled stat in the rare case two windows reach the check on one prompt at once, which claim() returning false for the loser already handles correctly. - The bus's wake signal (changed, a MutableSharedFlow(replay = 1)) had no extra buffer, so tryEmit returned false - discarded, since report() never checked it - whenever a collector hadn't yet consumed the replayed value, i.e. whenever any window was mid-dialog. That is the common case for a second missing dependency, not a rare one, so the periodic ticker (documented as covering only a closed target window) was doing the real signalling work for the routine case too. Nothing was ever lost - pending still held every prompt - but a second dependency could sit for up to a second before its rescan. extraBufferCapacity = 1 with DROP_OLDEST makes tryEmit always succeed; conflating repeated "go look" signals is exactly right for a rescan trigger. Also: renamed a test that implied claim() was proven atomic under real concurrency - runTest serialises both async bodies, so it never was, and the assertion it actually makes (a second claim of the same prompt fails) is what the new name says. Added a test for the ticker's periodic re-broadcast of a still-pending, unclaimed prompt, which had no coverage at all. MISSING_DEPENDENCY_RESCAN_INTERVAL_MS is internal now (from private) so that test - and any future one - can pin the real interval instead of a copy of it, matching this repo's own precedent for timing constants. Five doc sites (AGENTS.md and four KDoc cross-references) that still described the deleted channel/reoffer design are corrected to describe the current claim-based map registry instead. * fix(plugins): retire suppressed prompts and preserve live installer ownership --------- Co-authored-by: Shivang * fix(secrets): preserve hostnames in the host matching utility (#482) * fix(browser): catch native disposal exceptions to prevent incorrect sandbox disables Wraps native JxBrowser event listeners to prevent ObjectClosedException from escaping into the sandbox manager. Previously, unexpected browser disposal could be interpreted as a sandbox failure and consume the plugin restart budget. * fix(security): tighten browser secret domain matching * Wrap matcher regression tests to satisfy Detekt * Keep full hostnames visible in saved-secret display labels --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shivang * feat(settings): explain experimental Microkernel Mode before enabling it (#481) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names e… * fix(shortcuts): trigger key bindings on key release instead of key press (#492) * fix(shortcuts): trigger key bindings on key release instead of key press * fix(shortcuts): invoke a bound action on key-up, not key-down (#490) A shortcut's action ran from the KEY_PRESSED event, so holding a chord past the OS auto-repeat threshold fired it repeatedly (Cmd+N held down kept creating tabs), and the action always ran while the keys were still physically held. AWTKeyboardInterceptor.install() now recognizes a matching chord on KEY_PRESSED - claiming it (so it still doesn't leak to BossTerm/a browser while held) - but only arms it as a PendingShortcut. The bound action fires exactly once, from handleKeyReleased, when the primary key's own KEY_RELEASED arrives; a repeat KEY_PRESSED for the same key stays claimed without re-arming or re-firing. Releasing a modifier by itself can never match, since a modifier key is never allowed to arm in the first place. Focus moving to another window (or away entirely) clears any armed-but-unfired chord via a focusedWindow property listener, and uninstall() clears it too. dispatchAction gained a `perform` flag (default true, so every existing call site is unchanged) so handleKeyPressed can ask "would this dispatch" - reusing the exact same gate each action already had (canStepTabs, activePanelTabCount, panelCountState, ClosedTabHistory) - without performing the trigger, and handleKeyReleased later asks for real. This is one function answering both questions rather than two that could drift, at the cost of a narrow, documented race if the gate's answer changes in the few hundred ms between press and release. PluginShortcutRegistryImpl.dispatch has no such probe, so a matched plugin default arms optimistically and is confirmed for real at release. KeyboardShortcutInterceptor.kt (a second matcher against the same KeymapMatcher, not wired to any production caller today) gets the same arm-on-KeyDown/emit-on-matching-KeyUp treatment for consistency. New tests drive handleKeyPressed/handleKeyReleased directly with synthetic AWT KeyEvents (the same way the existing gate tests drive dispatchAction/dispatchIfCanStepTabs directly), covering: a key-up fires exactly once, a mismatched or modifier release does nothing, a repeat key-down neither re-arms nor re-fires, a gate closing between press and release is honored at release, and cancelling drops an armed chord. * fix: wokflows * fix(shortcuts): validate pending chord lifetime and release ownership * fix(shortcuts): fix MRU wedge, stale-arm swallow, and dropped-chord bugs (#490) Addresses the three substantive findings from review: - MRU tab cycling wedged open when the modifier released before the primary key. Arming (tabCycleActive/tabCycleModifierKeyCode) had moved to handleKeyReleased, where the cycling modifier is no longer guaranteed still down - the OS can deliver the two key-ups in either order, and releasing the modifier first meant arming for a release that had already happened. Extracted armTabCycleIfApplicable and call it from handleKeyPressed instead, where the modifier is guaranteed held (it's what made the chord match). - A lost KEY_RELEASED (swallowed by a native popup, a heavyweight surface, or an OS-level chord grab, with focus never moving so nothing else cleared it) left a stale armed entry that the repeat- claim check matched on keycode alone - so a later, unrelated bare press of the same physical key was silently swallowed, and its key-up fired the shortcut for a keystroke that was never meant to be one. PendingShortcut now records the arming event's four modifier flags, and the repeat-claim check requires them to still match; a mismatch falls through to normal re-matching instead, which also makes a stale entry self-heal on the next genuine press of that chord rather than staying stuck until focus changes. - Rolling one chord into another without fully releasing the first (Cmd+N into Cmd+T at speed) evicted it from the single pending slot, so its eventual release found nothing to fire and leaked to the focused component with no matching key-down to explain it. pendingShortcut is now pendingShortcuts, keyed by physical keycode, so concurrently-held chords on different keys get independent slots. Also: a release that matched and cleared an armed chord is now always consumed, regardless of whether the underlying dispatch reports handled - the key-down was already withheld from the focused component, so letting the key-up through alone on a refused gate or a plugin that unregistered mid-chord left a listener a key-up with no matching key-down to make sense of. tabCycleActive/tabCycleModifierKeyCode are now internal (from private) so a test can pin the arm-at-press behavior directly. KeyboardShortcutInterceptor.kt (still unwired to any production caller) gets the same stale-arm protection via onFocusChanged, for the "consistent key-up semantics" criterion the next person wiring it up will actually see. New/updated tests: armTabCycleIfApplicable's MRU-gating in isolation (arms for TAB_NEXT/TAB_PREVIOUS under MRU, does nothing otherwise or for an unrelated action), a structural proof that handleKeyReleased no longer arms the cycle itself, a stale-arm-with-different-modifiers regression, a two-independent-chords regression, and the gate-race test updated to assert consumption instead of the old handled-based return. 16/16 ShortcutKeyUpInvocationTest, all touched suites green. * fix(shortcuts): finish reviewed cancellation and headless regressions * refactor(shortcuts): resolve CI complexity findings * test(crash): tolerate transient Windows report read locks * test(crash): wait for drain marker deletion on Windows --------- Co-authored-by: Antriksh Co-authored-by: Shivang * fix(db): stop materializing inherited client routine access * fix(logging): mask a URL whose fragment comes before its query (#541) Preserve masked URLs when a fragment precedes the query. Original implementation and four regression tests by @arjun28115. Reviewed current-head Claude feedback and passing CI; existing adjacent nested-query behavior remains a documented follow-up. * test(config): isolate Chromium flags test from local environment variables (#507) * test(config): isolate Chromium flags test from local environment variables (cherry picked from commit c6bce0ded1a4157b84cb724577f89ff9a0cd3cc9) * test(config): pin the env-wins and blank-UNSET rules the envReader seam unlocks Addresses the Claude review of #507: - move the envReader seam declaration up beside settingsFile so the KDoc reattaches to envOverride and the seam is initialised before bootSettings = loadSync() (object initialiser declaration order); - previewValue test now exercises the environment outranking the setting and the blank-reads-as-UNSET guard, both untestable before the seam existed; - reword two comments that the seam made stale. * test(config): close out the second Claude review of #507 Addresses the re-review of 6bcd83c73 (comment text only, plus one hermeticity line in a test): - the init-order comment read inverted - it called an initialiser below the envReader declaration the hazard, when the hazard is an initialiser ABOVE it (which is exactly what the old layout had, with bootSettings declared above the seam); reworded; - 'envOverride reads the environment only' now stubs envReader to null for its own assertions, so it is hermetic by construction rather than by the BOSS_TEST_UNUSED_KEY naming convention. --------- Co-authored-by: Shivang * fix(search): route cross-window tab selections to target window (#500) * fix(search): route cross-window tab selections to target window * refactor(search): focus target window via WindowFocusManager, wire TabEventBus to kernel IPC * fix(search): honor closed target window, forward origin window in TabEventBus Addresses the Claude review of the cross-window tab selection change: - TabEventBus.selectTab now takes sourceWindowId (the dialog's own window) and forwards it as the IpcEventBridge envelope source, matching the origin contract the other twelve buses and EventBusServiceImpl rely on. - TabSelectEvent is @Serializable so kernel-mode forwarding emits structured JSON instead of the toString() fallback with a WARN. - onTabSelect honors WindowFocusManager.focusWindow's Boolean result: a closed target window now logs a warning instead of emitting into the void (the bus has no replay). - The 50 ms effect delay is named TAB_SELECT_FOCUS_GRACE_MS with a comment stating it is UX grace, not a correctness barrier. - TabSelectEventTest: the no-replay test proves live delivery first and only then asserts the late collector sees nothing; both tests use explicit subscription barriers instead of yield(); ipcBridge is reset in @AfterTest; KDoc states the tests pin the bus contract, not the shipped BossAppEventBusEffects filter. * docs(search): correct bus KDocs after destination-addressed TabEventBus Per Claude delta review at af7c292: - BossAppEventBusEffects file KDoc: note the TabEventBus exception to the window-filtered-by-sourceWindowId rule (Issues #498/#506). - TabEventBus KDoc: spell out the cross-process subscription consequence (subscribers must not set SubscribeRequest.sourceWindowId to their own window id; route on the payload's targetWindowId) and fix the 'this file's twelve sibling buses' wording. - TabSelectEventTest KDoc: the bus carries two properties, not three. --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux (#535) * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux - Problem: MenuShortcutBridge.getKeyShortcut() always set meta=true for Cmd bindings, which Compose Desktop maps to the Super/Windows key on Windows/Linux, so native menu items like Ctrl+N/Ctrl+, failed to fire. - Fix: resolve effectiveCtrl and effectiveMeta from SystemUtils.isMacOS before constructing KeyShortcut, matching the platform-aware mapping in KeymapMatcher and AWTKeyboardInterceptor. - Verification: ./gradlew :composeApp:desktopTest --tests "ai.rever.boss.keymap.ShortcutCaptureConsumersTest"; :composeApp:ktlintCheck; :composeApp:detekt. * fix(keymap): gate browser zoom/reload menu items, make mapping testable per-platform Addresses the Claude review of the menu accelerator change: - The View menu's Actual Size / Zoom In / Zoom Out / Reload items now carry enabled = hasBrowser like Back/Forward/Developer Tools. Their bindings are BROWSER-context and AWTKeyboardInterceptor declines them outside a browser tab, so before this the newly-live Ctrl accelerators on Windows/Linux could fire them from a terminal or editor tab. - The platform modifier mapping is extracted into the pure menuAcceleratorModifiers(hasCmd, hasCtrl, isMacOS), with the asymmetry comment moved with it. The test now pins both platform branches unconditionally on any runner; the bridge-level tests keep asserting the live SystemUtils wiring. - One-line comment keeping the legacy packed-keycode coverage attached to Key.N.keyCode.toString(). * docs(keymap): record the deliberate menu-gate asymmetry in three stale comments Per Claude delta review at 928d3b206: after gating the four View items with enabled = hasBrowser, three comments still described the pre-gate behaviour. - windowsWithActiveBrowser KDoc now enumerates all seven gated items. - activeBrowserWindows KDoc records that the gate is deliberately stricter than the dispatch target (sidebar-slot / background-split browsers lose Zoom and Reload from the View menu; the broader gate would swallow Ctrl+R and Cmd+[/Cmd+]). - The 'gate is stricter' test restates the asymmetry without the now-false claim that a sidebar browser still gets Zoom and Reload. --------- Co-authored-by: kshivang Co-authored-by: Shivang * fix: keep focus mode rail actions stable (#519) Keep focus-mode quick actions stationary in the collapsed rail while the drawer reveals beside it. Preserve rail interaction when the chevron dismissal catcher is present and keep heavyweight tooltips visible. Original implementation: Ayush Santosh Chhatre (@Ayush04-C). Consolidation source #534: Rohan Gaikwad (@rohangaikwad4), credited for the parallel stationary-rail implementation and permanent-activity-bar framing. Prior review-agent catcher and regression-test repairs remain separately attributed in the PR description and review record. Reviewed head 9e939539c8f971cd45e33aa336d261eb21d5a492: all nine CI checks pass, including three platform builds/tests and code quality. Claude's final import/KDoc findings were addressed; dev does not alter the PR's touched files. Manual visual checks remain recorded in the PR/review inventory. * fix(passkey): escape mobile WebAuthn HTML templates against reflected XSS (#546) Escape mobile WebAuthn template values in their HTML and inline JavaScript contexts to prevent reflected script injection. Original vulnerability analysis, contextual escapers and initial regression tests: Antriksh (@Antriksh1984). Prior agent follow-ups repair replacement-pattern expansion and add single-pass substitution plus additional regression coverage. Agent repairs are separate from the original submission score. Validation: all nine current-head CI checks pass; prior combined current-dev integration passed 167 Deno tests and 784 pgTAP assertions. Claude findings have been addressed with dispositions documented on PR #546. No production deployment is included. Co-authored-by: Antriksh * fix(project): stop the per-window recent-projects provider from leaking, without freezing KERNEL clients (#524) Stop window-owned recent-project collectors at disposal while keeping KERNEL watch streams connected to process-wide state. Original implementation by Antriksh; prior review integration and regression hardening are documented in the PR. Co-authored-by: Antriksh * Stop FileIcons keeping a second copy of the icon extension list (#525) * Stop FileIcons keeping a second copy of the icon extension list `FileIcons.forFile` gated `LanguageIcons.forExtension` behind a `when` whose 52 branches were byte-identical: each listed some extensions and then called `LanguageIcons.forExtension(extension).toFileIconInfo()`. The branches carried no behaviour, only a vocabulary, and that vocabulary is already declared in LanguageIcons' own `when` in the adjacent file. It is one of the duplicated language tables BossConsole#75 is about, and #358 did not reach it: that PR consolidated the host and out-of-process language-ID tables, while the icon layer kept its own. The copy had already drifted, which is the failure mode #75 describes. Eleven extensions LanguageIcons has icons for were missing from the gate and fell through to the generic file icon: astro, eslintignore, eslintrc, gql, graphql, pom, prettierignore, prettierrc, prisma, proto and styl. Nothing reported that; the file just rendered as a blank document while the icon sat one file away. Measured before changing anything: the gate routed 108 extensions and LanguageIcons knows all 108, so no extension loses an icon. The gate was a strict subset, so deleting it is additive. `forExtensionOrNull` is the nullable variant that lets a caller ask whether an extension is recognised without comparing against `unknown`, which a real mapping could legitimately return. `forExtension` keeps its total contract and delegates. The 19 explicit branches stay ahead of the delegation because they deliberately disagree with the language mapping: a `.xml` is a config document, `.bat`/`.cmd` take PowerShell's colour, `.env` is a lock, and `jar`/`war`/`ear` are archives carrying Java's colour rather than Java source. `forSpecialFileName` still runs first, which is why `pom.xml` is Maven rather than XML. FileIconsLanguageDelegationTest pins all of it: the fallback for a genuinely unknown extension, a spread of the previously gated extensions, each of the eleven that had drifted, every override that must keep winning, the jar case, and that `forExtension` is still total. 47 tests in the module, 0 failures. Module ktlint and detekt clean; the two existing detekt baseline entries were rekeyed to the renamed function rather than widened. Co-Authored-By: Claude Opus 5 (cherry picked from commit a1c298ef63aeea9c676c2f45022b87007d6258c2) * style(file-icons): fix stale section comments left by the delegation change The 'Shell scripts' comment described branches this change removed, and the empty slot under 'Databases' is where the removed sql branch sat. Comments only; no behavior change. (cherry picked from commit 86a031c6adad92f5b74b4e83dd98b142c3706880) * style(file-icons): drop the value-identical gradle branch and stale docs Addresses the Claude review of #525: - remove the explicit "gradle" branch from forFile: it was value-identical to LanguageIcons' own mapping, i.e. exactly the duplication this PR removes; gradle files now resolve through the forExtensionOrNull delegation, pinned with a non-special name (libs.gradle); - the build.gradle assertion tested forSpecialFileName, not the removed branch; the xml-vs-maven override is now pinned directly; - move the Apple property list comment back above the plist branch and drop the orphan blank line at the Windows batch comment; - delete the orphaned KDoc left by the forExtension rename; - prune the baseline entry for the png line that is a block body now. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(plugin-store): stop the update check mis-ordering versions it cannot parse (#539) * fix(plugin-store): stop the update check mis-ordering versions it cannot parse `PluginRepositoryManager.isNewerVersion` split a version on "." and dropped any segment that was not a bare integer. Dropping rather than zero-filling shifts every later segment into the wrong position, so the comparison was made against the wrong field. Two cases a user would notice: "1.0.0+build.7" parsed as [1, 7]. Compared against an installed [1, 0, 0] the 7 landed in the minor position and won, so the store offered an update to the version already installed, on every check. SemVer section 10 says build metadata is ignored for precedence. An installed "1.0-beta.5" parsed as [1, 5], so the 5 sat in the minor position and beat the 2 of a genuine "1.2.3" release. The real update was never offered at all. The codebase already has one answer for this. SemanticVersion describes itself as the sole version-comparison primitive for plugin update and floor checks, and PluginUpdateManager uses it. This was the third comparator and the only one that disagreed with it; across a small matrix of realistic version strings the two disagreed on 32 of 156 ordered pairs. plugin-dependency has no project dependencies of its own, so taking it as a dependency here cannot introduce a cycle, and plugin-updater already does it. One behaviour is preserved deliberately rather than inherited: a plugin whose installed version cannot be parsed is still offered an update. It is already in a broken state, and refusing every future update would strand it there. An unparseable candidate is never offered, because nothing can be said about it. Ten tests, of which five fail against the previous implementation. Co-Authored-By: Claude Opus 5 * docs(plugin-repository): correct version-shift comments and cross-reference the two comparators Addresses the Claude review of #539 (comments and one log line; no behaviour change): - the old code read '1.0.0+build.7' as [1, 0, 7] (the 7 in the PATCH slot, not the minor) and '2.0.0-rc.1' as [2, 0, 1] returning true on 1 > 0 at the third position; the test and KDoc comments now say so; - the test KDoc now names which cases are regression tests and which are guards - most also pass against the old comparison; - both isNewerVersion KDocs now cross-reference each other and state the deliberate fail-open vs fail-closed divergence on unparseable installed versions; - checkForUpdates documents that it has no production caller today (Toolbox path is PluginUpdateBridge -> PluginUpdateManager) and logs when a candidate is refused because its store version is not parseable, keeping 'no update offered' and 'we could not tell' distinguishable. * test(plugin-repository): close out the second Claude review of #539 Addresses the re-review of 20b7e6ff5: - the test KDoc listed six regression cases, but 'a release is newer than its own pre-release' passes against the old comparison too ([1,2,3] vs [1,2] decides at the third position both ways) - it is a guard, as the PR body already said; the list now names the five real regressions; - the checkForUpdates KDoc claimed the function is 'pinned by tests' while nothing called it; it is now driven end to end by two new tests (a newer store version is offered; an unparseable store version is never offered), which also cover the new offerUpdateIfNewer helper and its debug log; - the KDoc now states the honest reason the callerless function is kept: the repository layer should answer the same question the updater does if the paths are ever merged. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(downloads): stop a hostile file name throwing out of the download handler (#540) * fix(downloads): stop a hostile file name throwing out of the download handler FileNameSanitizer.sanitize runs on the name the download source chose, so its input is whatever a server puts in Content-Disposition. Two inputs got the wrong answer. A long extension threw. Nothing bounds how long an extension is: it is whatever follows the last dot. A name of "a." plus 300 characters gives an extension of 301, and subtracting that from the 255 limit gave a negative budget that reached take(), which rejects a negative count and throws out of the handler. The extension is now abandoned when it cannot fit, rather than shortened, because a truncated extension is not the file's type and inventing one is worse than having none. A name of only dots came back ending in a dot. For ".", ".." or "...", the extension computed in step 4 is a lone ".", and the "ensure at least one character" fallback appended it raw to produce "download." That re-created exactly the trailing dot step 5 removes, and a trailing dot is what is invalid on Windows. Truncation could re-expose one too, so the rule is re-applied to whatever the cut produces. The file had no tests. Eleven are added, four of which fail against the previous implementation. They also pin the behaviour that was already right, so the Windows device names, the traversal reduction and the control-character handling cannot regress silently. Verified against the local ktlint and detekt gates. Co-Authored-By: Claude Opus 5 * fix(downloads): abandon an oversized extension instead of truncating it Review follow-up on @arjun28115's fix: the documented intent is that an extension which cannot fit in the 255-character limit is abandoned rather than shortened (a truncated extension is not the file's type), but the budget <= 0 branch kept a prefix of the extension. Preserve the base name only, cut to the limit, and pin the behavior with a regression test. 12 tests, 0 failures; ktlint and detekt clean. * fix(downloads): recompute the cut's extension and re-defuse reserved names Review follow-up on @arjun28115's fix: - truncate() took the extension computed before the trailing-dot trim. For exactly the hostile shape this PR targets - a name ending in a dot - that copy is a stale lone '.', so a real '.pdf' that fit was dropped and the download landed extensionless. The extension is now recomputed from the string being cut, as the helper's KDoc always claimed. - The cut's trim can also shrink the base onto a reserved device name step 4 missed: 'CON ' (space before the dot) is not a reserved segment, so with an oversized extension the result was bare 'CON', which Windows refuses. The cut result is now defused like step 4. - The device-name test title overclaimed ('whatever their case or extension'); it now names what it covers, pins the defused shape, and includes the trailing-space input the description claimed was tested. 14 tests, 0 failures; ktlint and detekt clean. * fix(downloads): compare a device name at the first dot, as Win32 does The review of 9d04f35 is right and the disposition that closed finding 2 was not. A device name followed by an extension is still the device: Microsoft's naming rules say NUL.txt and NUL.tar.gz are both equivalent to NUL, and this file already agreed, since step 4 rewrites CON.txt to _CON.txt. The check compared the segment before the LAST dot, which leaves two holes that are the same underlying fact. Multi-dot names were missed entirely. "CON.tar.gz" was compared as "CON.tar", which is not reserved, so it passed through untouched. Truncation could manufacture a device out of a name that was not one. "CONSOLE." followed by 251 characters is not reserved at step 4. Cutting it to fit the limit left "CON." and the rest, which Windows resolves to the console, so the download writes nowhere and no file appears. The defuse added in 9d04f35 did not catch it, because it compared the whole cut rather than the base, and only the budget <= 0 branch could ever match. Both close by comparing the segment before the first dot, in one helper used by step 4 and by the cut. Trailing spaces are trimmed before the comparison for the same reason: Windows ignores them. Defusing prepends one character, which would push a full-length result to 256. The cut is therefore redone one character shorter when that happens, which is always enough because the prefix is exactly one character. Three tests, all of which fail against 9d04f35. The last asserts the invariant rather than a case: no input in the list comes back as something Windows would resolve to a device. The title of the existing device test said "with a single extension", which was accurate before this and is not now, so it has been corrected rather than left to mislead. 17 tests, ktlint and detekt clean. Co-Authored-By: Claude Opus 5 * test(downloads): pin the retry-that-defuses cut and the CONSOLE redo shape Closes the two test-only items from the third Claude review of 40700c137: - new test for the one path where a defused cut survives into the returned value: the 255 cut and the 254 redo both trim onto CON, both defuse, and the result lands exactly at the 255 limit; - the CONSOLE. + 251x test now pins the full redo shape (CO. + 251x) instead of a non-device check that would also accept a fallback name; - the padded-name test comment now says step 4, not the cut, defuses it; - truncate's comment states the cutTo limit + 1 bound that makes one redo always enough, and the step 4 extension local says it serves the step 6 fallback. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(browser): prevent memory leak by unifying browser teardown paths (#502) Unify external browser closure with idempotent full teardown. Includes reviewed EDT dispatch, unconditional unregister and regression guards. Original contribution: Anshul Pundhir (@pundhiranshul). Review-agent hardening is documented in PR #502. Co-authored-by: pundhiranshul * fix(runner): honor main-panel rerun delay and document unsupported settings (#486) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and name… * fix(repository): a cancelled store call is not a network failure (#531) * fix(repository): a cancelled store call is not a network failure; apply downloadPlugin's rule to every store call * test(repository): pin propagation and the ratePlugin path; fix stale call-site comments Claude review follow-up on #531, on top of aryanbajaj868's fix: - RemotePluginRepositoryCancellationTest: ratePlugin is driven by the held server too (it refuses before a request without an access token; a.b.c is a structurally valid three-part token with no admin claim), and every store call is now asserted to propagate the caller's CancellationException out of the method, not merely to stay out of the log - a swallowed cancellation is also no ERROR line, so the log assertion alone cannot see that half. - onStoreFailure moved into the Helper Functions section; the test server's single dispatch thread is documented. - Three call-site comments (StoreMissingDependencyInstaller, PluginStoreVersionBridge, PluginLoadGateRecovery) restated their rationale: the bundled repository now rethrows a caller's cancellation, but the PluginRepository interface permits either shape, so the flattened handling and the rethrow guards stay. --------- Co-authored-by: Shivang * Restrict completed_authentications to the service-role Edge Function (#536) * Restrict completed_authentications to the service-role Edge Function completed_authentications holds the freshly minted access_token and refresh_token for the cross-device (QR) login handoff. Its anon policies were each guarded only by `session_id IS NOT NULL`, and session_id is NOT NULL on every real row, so all three were `USING (true)` written the long way. Nothing required the caller to know a session id, so the "unguessable UUID acts as a bearer token" model the surrounding comments describe was never enforced. With `GRANT ALL ON TABLE ... TO anon` and an anon key that is public by construction (it ships in the desktop app and this repo is open source), that was reachable by anyone who can reach PostgREST: 1. Token harvest. Selecting the table returns every in-flight login's access and refresh token. Polling faster than the legitimate desktop wins the race to any QR-logging-in user's session, which is account takeover. 2. Denial of service. A single DELETE wipes all in-flight logins. 3. Session fixation. anon INSERT pre-seeds a victim's session_id with attacker-controlled tokens, which the victim's desktop imports as its own. Reported as BossConsole#528 by @Rushikeshiitb, including the fix shape used here. Safe because no client reaches the table directly any more, which I verified rather than took on trust: grepping the desktop source for completed_authentications returns nothing outside build output, and the desktop polls GET /passkey/auth/status/{sessionId} instead (SupabaseApiClient.kt:103). Every read and write happens inside the passkey Edge Function, whose Supabase client is built with SUPABASE_SERVICE_ROLE_KEY (passkey/index.ts:36). service_role bypasses RLS, so the anon and authenticated policies and grants are dead code on the live path. The authenticated policies go with the anon ones because the flow does not use them either, and leaving a role with standing access to a table of bearer tokens is the condition that made this exploitable. Follows 20260910120000_restrict_plugin_downloads_rls.sql: revoke the base-table privileges as well so the gate does not rest on RLS alone, including from PUBLIC, since a grant there would survive revoking the two named roles. The six policy names were checked against 20251023000013_rls_policies.sql programmatically. A mistyped name in DROP POLICY IF EXISTS is a silent no-op that would leave the hole open, and the pgTAP test asserts absence rather than trusting the drop. Co-Authored-By: Claude Opus 5 * test(db): carry over service_role privilege assertions from #530 BossConsole#530 by @Rushikeshiitb asserted that service_role keeps every verb on completed_authentications after the lockdown; this suite only checked the surviving policy. Merge the two companion submissions by adopting those four assertions (plan 11 -> 15) so a too-aggressive revoke of the service-role grant fails the pgTAP suite. * fix(db): revoke client EXECUTE on the expired-row cleanup RPC cleanup_expired_completed_authentications() is granted to anon and authenticated at 20251023000014_grants.sql:240-241, but no client code calls it: it returns void, only deletes rows past expires_at_timestamp, and after the table revoke it cannot execute its DELETE as a client anyway. Revoke the client EXECUTE so no client role keeps any handle to the token table, and pin it with three pgTAP assertions (plan 15 -> 18). Answering the open question raised in the PR description. * fix(db): also revoke PUBLIC EXECUTE on the cleanup RPC PostgreSQL grants EXECUTE on functions to PUBLIC by default, so the previous REVOKE ... FROM anon, authenticated left =X/postgres in the function ACL and has_function_privilege kept returning true for both client roles (pgTAP tests 16-17 failed on the previous head). Revoke from PUBLIC as well, matching the table revoke; service_role keeps its explicit grant (verified f|f|t in a scratch PostgreSQL 17). * test(db): pin the policy set and the verbs a named REVOKE leaves behind Claude's review of this PR found the two per-role policy checks would pass a policy created without a TO clause (pg_policies records it as roles = {public}, so it applies to everyone yet matches neither client role), that TRUNCATE - the one verb row level security cannot stop - was unasserted, and that authenticated was missing its UPDATE assertion. - Assert the full policy set is exactly the service_role one instead of checking each client role separately; verified on scratch PostgreSQL 17 that an unscoped policy fails the new assertion and passes the old one. - Assert TRUNCATE, REFERENCES and TRIGGER for both client roles, matching the precedent suite plugin_downloads_access_test.sql. - Add the authenticated UPDATE assertion, so both client roles are now covered per verb as the header promised. - Reword the cleanup-RPC comment to lead with the REVOKE EXECUTE as the actual gate and flag the stale SECURITY DEFINER header comment on cleanup_expired_completed_authentications (20251023000003_passkey_functions.sql:221) that made the fallback argument fragile. plan 18 -> 24. Verified all 24 assertions on a scratch PostgreSQL 17 replica of the post-migration state. --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang Co-authored-by: Shivang * fix(security): stop two audit logs accepting forged rows from any client (#538) * fix(security): stop two audit logs accepting forged rows from any client `secret_access_log` and `plugin_api_key_logs` are the audit trails for secret access and for plugin-store API key usage. Both had an INSERT policy of `WITH CHECK (true)` with no `TO` clause, and a policy with no `TO` clause applies to PUBLIC, which includes anon and authenticated. Both tables are writable by those roles. secret_access_log has an explicit `GRANT ALL ... TO anon, authenticated`. plugin_api_key_logs has no explicit grant, but 20251023000014_grants.sql sets `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon, authenticated` and that table is created later, so it inherits it. The anon key ships in the desktop client, so anybody could append arbitrary rows to either log. secret_access_log.user_id was unconstrained, so a row claiming another user viewed or shared a secret could be written at will, and the table's own SELECT policy then shows it to the named user and to every admin as though it were real. A genuine access event could equally be buried under forged entries, which is the failure an audit log exists to prevent. Neither policy comment described this. The plugin_api_key_logs policy is named "Service role can insert API key logs", which is what it was meant to be and not what it did. No legitimate writer is affected. Every function that writes secret_access_log inserts auth.uid() as user_id, which the new predicate permits. plugin_api_key_logs is written only by log_api_key_action(), which is SECURITY DEFINER and granted to service_role alone, so it bypasses RLS and needs no INSERT policy at all. Nineteen pgTAP assertions cover the fix, with privileges checked per verb rather than with a single ALL check, because has_table_privilege(..., 'ALL') is true only when every verb is held and one re-granted verb would pass an ALL-shaped assertion while leaving the hole open. Co-Authored-By: Claude Opus 5 * fix(db): close the definer-RPC door Claude's review opened Claude review found the plugin_api_key_logs half did not close the hole: log_api_key_action() is SECURITY DEFINER owned by postgres, and clients still held EXECUTE on it (PostgreSQL grants EXECUTE to PUBLIC by default, plus the schema-wide default-privilege grants inherited by this later-created function; the explicit service_role grant is additive). Any client could forge API-key history through the RPC even with the table locked. - Revoke EXECUTE on log_api_key_action() from PUBLIC, anon and authenticated; service_role (the only caller, via the plugin-store Edge Function's service-role client) keeps it. - secret_access_log: revoke authenticated INSERT too - every writer is SECURITY DEFINER owned by postgres and bypasses RLS without client table privilege, so a scoped client INSERT could only forge self-attributed rows. Drop the INSERT policy instead of recreating it scoped, since no client statement can insert anyway. - Tests: assert the RPC EXECUTE rejections/retention (3), flip the authenticated INSERT assertion, replace the three policy-shape assertions with an absence check (plan 19 -> 20). * fix(security): close the second RPC and the verbs a named REVOKE leaves behind Builds on kshivang's 2328a13, which closed the definer-RPC door the review found. Three things the review named are still open after it. update_api_key_last_used() has the same shape as log_api_key_action(): same file, SECURITY DEFINER, EXECUTE never revoked, service_role grant additive. A client can still stamp last_used_at on any key id, which corrupts the same audit story from the other end and makes a dormant key look live. Revoked, with the same three assertions the other RPC now has. Revoking named verbs leaves the rest of the original GRANT ALL: TRUNCATE, REFERENCES and TRIGGER. TRUNCATE is the one that matters, because it is not subject to row level security at all, so no policy can be its backstop. anon already lost these to the REVOKE ALL; authenticated did not. The per-verb argument in the test header was applied to one table. For plugin_api_key_logs only anon INSERT, authenticated INSERT and authenticated SELECT were asserted, so a re-grant of anon SELECT, UPDATE or DELETE, or of authenticated UPDATE or DELETE, failed nothing. That is the precise failure the header set out to prevent. The API key log policy's command is pinned too: a policy recreated as FOR ALL under the same name and role would otherwise pass every other assertion while granting far more. Six assertions are behavioural rather than catalogue-shaped, following plugin_downloads_access_test.sql: set the role, attempt the write, assert 42501. Each is one of the three defects in the migration header, tested directly rather than inferred from a privilege bit. Plan 20 to 39. I still cannot run supabase test db locally: no Supabase CLI, psql or Docker on this machine. CI's Database job remains the real check. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang * refactor(startup): split monolithic main.kt into modular startup phases (#450) * fix(microkernel): resolve startup failures and stabilize service lifecycle * fix: CI workflows * refactor(startup): split monolithic main.kt into modular startup phases * fix: CI and workflows * fix(startup): preserve failure notices and safe descendant teardown * test(startup): pin forwarding and shutdown extraction contracts * fix(kernel): report a restart cap once per failed generation * style(startup): format extraction regression tests * fix(startup): keep restored comments scoped to window composition * fix(startup): separate cancellation from shutdown timeout * fix(startup): acquire instance ownership before starting kernel * fix(startup): release ownership on construction failure and retry partial natives * fix(microkernel): retain repair notices and await forced process exit * test(startup): simplify interrupted native stream fixture * fix(startup): publish extracted native files atomically * fix(startup): coalesce retained diagnostics and normalize saved mode writes * fix(startup): unify effective mode gates and bound operator diagnostics * fix(settings): retain pending process mode and report save failures * fix(settings): synchronize pending mode across windows and explain unavailable builds * fix(settings): let shared preference refresh own displayed mode * fix(settings): use themed save errors and strengthen availability coverage * fix(build): restore store synchronization imports after dev integration * fix(settings): show running readiness and load mode support asynchronously * fix: failed menu save in BossWindow * fix(startup): integrate current dev, restore merge-dropped startup wiring The prior dev merge reintroduced closed #419 content on top of current dev, leaving duplicate single-instance blocks, a parallel BOSS_MODE preference writer, and dropped shutdown-registration wiring. - Remove the duplicate single-instance block in main.kt; keep the CliBootstrap.forwardToExistingInstance path - Restore the Phase 5 shutdown-hook registration (ShutdownSequence.register { kernelBootstrap } + lock-acquired log) that the merge dropped - Drop the branch's parallel EnvVars/ConfigLoader mode writers in favor of dev's canonical MicrokernelModePreference (#481); AdvancedSettings is byte-identical to dev - Route CLI deep-link forwarding through dev's forwardDeepLinkWithRetry (action links: 1 attempt; others: 3) without short-circuiting a multi-file selection after one failure - Restore awaitForcedExit waits + takeUnless{isAlive} unregister guards in OutOfProcessPluginSpawnerImpl cleanup/terminate (load-bearing for reaping; dev keeps the window-scoped hashed processId) - Remove dead microkernelMenuLabel; update CliBootstrap/EnvVars tests and AGENTS.md BOSS_MODE section to match dev's model * fix(startup): close the env_vars reader split and review follow-ups Env vars: - One shared normalization (EnvVarsFormat, commonMain) for the env_vars file: both in-repo readers (ConfigLoader's BOSS_MODE tier and MicrokernelModePreference's toggle reader) now parse identical line semantics, so Settings and the runtime gate can no longer state opposite modes for one file. export KEY=value is now recognized by both (shell convention; env_vars doubles as the secret-manager plugin's key file), and an indented '# BOSS_MODE=KERNEL' is a comment in both readers. - ConfigLoader.loadEnvVars widens its catch to Throwable: an ExceptionInInitializerError or NoClassDefFoundError from BossDirectories' own static init can no longer escape ConfigLoader's and poison every later getConfig call. - Restored dev's per-attempt 'Open request forwarding completed' INFO log in CliBootstrap (the extraction had left only the failure WARN). - A fully successful KERNEL spawn is logged, not toasted: only missing JARs or failed spawns report to the startup notice queue (previously every healthy launch showed a 12s developer-worded toast). Docs: - AGENTS.md: the env_vars priority paragraph and the microkernel toggle section no longer contradict each other - the toggle now activates the next launch via the env_vars tier, applied on restart, with the latched startup snapshot still as the 'restart required' comparand. - startupEnabledLatched KDoc updated to the post-env-vars-tier reality. Tests: - EnvVarsTest: export-prefixed and indented-comment line pins. - MicrokernelModePreferenceTest: indented-comment reads as disabled; new test asserts the settings reader and ConfigLoader reader agree line by line across the sample matrix. - ServiceStartupSummaryTest: the notice gate (no toast on a clean spawn, toast on missing/failed services). --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges (#505) * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges BossConsole#53 built a ServerInterceptor (ProcessIdentityInterceptor / ProcessTokenRegistry) that verifies which process is calling the kernel's out-of-process IPC surface, independent of anything a request body claims - but only wired it into PluginUIServiceBridge. The other fourteen service bridges, documented in that interceptor's own KDoc as "none of them authenticated today", still trust every caller unconditionally. SecretServiceBridge and RoleManagementServiceBridge are the two where that actually matters: the whole password vault (list/search/create/update/ delete/share/unshare) and RBAC (roles, permissions, grants) had no caller attribution at all. Any out-of-process plugin that can reach the kernel's local IPC socket could read, mutate or share every secret, or mint and revoke permission grants, with nothing to tell one plugin's calls from another's. Both bridges now require AUTHENTICATED_PROCESS_ID on every RPC - reads included, not only mutations, since an unattributed read of the vault list is exactly the exposure this closes - and refuse with PERMISSION_DENIED before the request ever reaches the underlying provider when there is none. Every mutating call also logs the verified caller identity, so a compromised or rogue plugin's activity is attributable after the fact. Every legitimate out-of-process plugin already carries a valid credential in production (ChildProcessBootstrap attaches ProcessTokenClientInterceptor whenever BOSS_PROCESS_TOKEN is set, which ProcessSpawner sets for every child it spawns), so this closes the gap without changing behavior for any real plugin. Tests exercise both bridges over a real gRPC server and real client, mirroring PluginUIServiceBridgeTest's own harness: an authenticated caller succeeds, an anonymous one is refused, and - the assertion that actually matters - the underlying provider is never invoked on a refusal. * test(kernel): pin the identity gate on late-registered bridges and close the updateSecret gap - SecretServiceBridgeTest: updateSecret gets its own anonymous-refusal test, matching the vault mutations it already covered. - SecretServiceBridgeTest: a service registered on an already-started server (BossIpcServer's late-service fallback registry, as KernelBootstrap.registerPluginServices does) is still gated by ProcessIdentityInterceptor - anonymous calls are refused before the vault is reached, token calls go through. This pins the registration order (fallbackHandlerRegistry before intercept) the production path relies on. - KDoc on both authenticatedCallerOrRefuse helpers: the AUTHENTICATED_PROCESS_ID snapshot is correct for unary RPCs; a streaming bridge must read CURRENT_IDENTITY instead (see PluginUIServiceBridge.streamUI). * test(kernel): tighten RBAC/secret refusal assertions, add revoked-token coverage, fix stale KDocs RoleManagementServiceBridgeTest: getRolePermissions, validateRoleName and validatePermissionName were asserted only as "some StatusException", with no counters on FakeRoleManagementProvider at all - a bare assertFailsWith would pass just as happily on UNAVAILABLE or UNIMPLEMENTED, contradicting the class's own KDoc claim that every refusal test proves the provider was never reached. Added the three missing counters and now assert PERMISSION_DENIED plus a zero counter on every refusal in both bridge test files, not just the two "an anonymous caller is refused" tests. SecretServiceBridgeTest: the late-registration test called tokenRegistry.issue(DEFAULT_PROCESS) for the same id setUp already issued for - issue() replaces and invalidates the previous token by design, so this silently killed the credential the shared `authenticated` stub carries for the rest of the class. Now issues a distinct id. lateServer teardown moved into a finally so a failed assertion doesn't leak a bound server. Moved an orphaned KDoc comment back onto FakeSecretDataProvider (SingleServiceRegistry's insertion had left it dangling above the wrong class). Added a revoked-token test to both files: identityFor returns null for a token the registry no longer holds, the post-reapChildren/post-respawn case, previously untested - every existing refusal test only covered "no credential at all", not "a credential that was live and got pulled". Two stale KDoc facts fixed in boss-ipc: ProcessIdentityInterceptor's comment named PluginUIServiceBridge as the only bridge checking identity (now three of fifteen, with SecretServiceBridge and RoleManagementServiceBridge named); BossIpcServer's tokenRegistry KDoc claimed a null registry "leaves every service's behaviour exactly as it was", which stopped being true for a server hosting either of the two fail-closed bridges - a null registry there means every call refused, not merely unauthenticated as before. * docs(ipc): correct the unauthenticated bridge count in the interceptor KDoc The kernel registers 16 bridges on its ipcServer (15 in KernelBootstrap.registerPluginServices plus PluginUIServiceBridge at KernelBootstrap.kt:475); three authenticate, so thirteen are unauthenticated, not twelve. Per the Claude review's one-word count fix. --------- Co-authored-by: kshivang * refactor(plugin-launchpad): address review feedback on permissions, rollback, and staging safety - Set default template requiredPermissions to emptyList() for open access - Loosen validator to accept valid RBAC permission strings and handle emptyList() - Retain staged dev artifacts on HostOffline instead of blindly pruning - Implement symmetric multi-window rollback differentiating hot-reload from first-link - Add BufferUnderflowException and IndexOutOfBoundsException safety in bytecode fallback parser - Add unresolvable synthetic class fixture in closed-archive test to exercise bytecode fallback - Deeply validate startup dev JARs (ZIP validity, manifest existence, pluginId match) before swapping - Gate external Gradle test execution behind boss.test.e2e.gradle system property - Disallow '.' in SingleInstanceManager plugin dev identifier validation - Add DevPluginRollbackTest suite covering multi-window rollback and staging safety * test(launchpad): add hot-reload rollback regression and enforce permissions field precision - Add hot-reload failure rollback test in DevPluginRollbackTest restoring prior jar and enabled state - Enforce requiredPermissions emission in PluginScaffolder with explicitNulls = false in launchpadJson - Pass all ktlint and detekt linter checks with zero violations - Verify headless launcher test and benchmark test suite * test(db): gate the two client-grant shapes that produced #547 three times (#596) * test(db): gate the two client-grant shapes that produced #547 three times `ALTER DEFAULT PRIVILEGES` in 20251023000014_grants.sql hands every later table client grants, and three tables then shipped with a permissive write policy on top of that: plugin_downloads (#488), completed_authentications (#536) and secret_access_log / plugin_api_key_logs (#538). Each was caught by a person reading a migration. identity-disclosure audit. That audit asks who can READ identity. Neither it nor the default-privileges change reaches the shape above: a default-privilege revoke is not retroactive, CHECK 3 filters `cmd in ('SELECT','ALL')` so an INSERT policy is invisible to it, and CHECK 3b filters `not relrowsecurity` while both #538 tables had RLS enabled with a permissive policy. Adds supabase/audit/client_grant_audit.sql with two exact catalog checks: * G1 names any public table a client role can reach without RLS. No allowlist, because all 34 tables in the schema enable RLS today. * G2 names any INSERT/UPDATE/DELETE/ALL policy reachable by anon, authenticated or PUBLIC whose predicate is the literal `true`. G2 keys on the predicate, not on a missing `TO` clause. The missing `TO` is how all three slipped past review, but 101 of the 128 policies in this schema omit it, so gating on that would report most of the schema. Predicate plus write plus client role matches two policies in the whole schema, and both are the ones #538 removes. G2 is asserted as a subset rather than as HEALTHY while those two are still on dev, so the gate passes before and after #538 and still fails on anything new. Co-Authored-By: Claude Opus 5 * fix(db): exclude restrictive policies from write lint * docs(db): state audit limits and cross-suite regression coverage * docs(db): name the exact companion policy assertion --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(browser): actually warn before downloading an executable file (#484) * fix(browser): actually warn before downloading an executable file warnForExecutables defaults to true and its KDoc promises a warning before a recognized executable finishes downloading, but the branch that was supposed to show it was an empty TODO block ("for now, just proceed") - so the download always proceeded silently regardless of the setting, for every user, with no way to turn the (nonexistent) warning off since there is no Settings UI for it either. confirmExecutableDownload mirrors pickSaveFile's own pattern exactly, since both are called from the same synchronous StartDownloadCallback and both need a blocking answer before it returns: a Swing confirm dialog run via SwingUtilities.invokeAndWait, failing closed (treated as cancel) if the dialog itself cannot be shown, matching how a null save path from pickSaveFile already cancels the same callback. Declining releases the claimed save path and cancels the download the same way the existing parent-directory-failure bail does, just above it in the same callback. * fix(browser): close executable consent and cancellation gaps * fix(browser): bind download consent to a usable window and explicit choice * feat(browser): add a user-facing toggle for the executable download warning Settings > Browser > Downloads > "Warn before downloading executable files", on by default. BrowserSettings.warnForExecutables is persisted through the existing BrowserSettingsManager JSON store alongside the other browser toggles, and FluckEngine reads it live at download time instead of the unpersisted DownloadSettings copy, so a change applies to the next download without restarting BOSS. Disabling it skips only the executable warning; filename checks, save-path handling and cancellation cleanup are unchanged. Addresses the maintainer's follow-up on #484. * fix(settings): index the executable-download-warning toggle for search SettingsSearchIndexDriftTest failed CI on all three platforms: the new "Warn before downloading executable files" toggle (Settings > Browser > Downloads) had no entry in SettingsSearchIndex, so Settings search could not find it. Added a Downloads group under the FLUCK section, extracted into its own downloadsEntries() helper alongside the existing fileAndLinkEntries() to keep browserEntries() under detekt's LongMethod threshold. * fix(browser): pin the warning toggle's persistence and volatility - BrowserSettingsManager: settingsFile is now an internal var with a reloadForTest() seam (same pattern as DefaultAppsSettingsManager), so the persisted-value tests exercise the real saveSettings/load path instead of re-stating the assignment; covers save+reload round-trips, a file predating the key, and a corrupt file - ExecutableDownloadConsentTest: the live-read test re-implemented the call site, so it now pins the StartDownloadCallback argument textually (SettingsSearchIndexDriftTest idiom) - a revert to the unpersisted downloadSettings copy now fails the build - BrowserSettings.warnForExecutables is @Volatile: written on the Compose UI thread, read on the JxBrowser download callback thread - DownloadSettings.warnForExecutables removed: no reader left it; the live flag is BrowserSettings.warnForExecutables * test(browser): make the persistence tests measure the reload, not the assignment Addressing the second Claude review of 70a4874: - default test asserted process-global state that the test's own @BeforeEach causes to be loaded from the developer's real settings file - it would pass in CI and fail on a machine with the toggle off. It now pins the persisted shape's default; the in-memory direction is carried by the predating-key reload test. - the round-trip test moved the in-memory value away between save and reload, so deleting the load-apply in BrowserSettingsManager now fails it. - the whole persisted shape (all eleven fields, including the four system-property mirrors) is snapshotted in @BeforeEach - after the first access that may trigger the manager's real-file load - and restored in @AfterEach, since reloadForTest() rewrites all of them. - reloadForTest KDoc states the deliberate no-reset divergence from DefaultAppsSettingsManager.resetForTest. * fix(browser): test the download dialog's owner resolution and surface it R1: the dialog-owner resolution (active window, else a fallback, else refuse) was the only new decision in the executable-download warning with no test - the branch that silently cancels a user's download had nothing pinning it. Extracted pickDialogOwner(activeWindow, candidates), mirroring resolveActionableWindowIdFrom's own pure-function split, and added cases covering active-window-wins, non-displayable-active falling back, and no-usable-owner refusing. R2: the fallback resolved exactly one window id and refused outright if it wasn't displayable, even when another registered window was open - reachable in the interval where a window has been disposed but its EDT unregister hasn't run yet. pickDialogOwner now takes an ordered list of candidates and scans all of them; WindowFocusManager. candidateWindowsForDialogOwner() supplies that list (the resolved fallback window first, then every other registered window), covered by a test where the first candidate is stale and a later one is usable. R3: nothing called toFront()/requestFocus() on the resolved owner, so a background BOSS could still show this modal somewhere the operator doesn't see, with the JxBrowser callback thread waiting behind it. Already running on the EDT inside confirmExecutableDownloadOnEdt's own Runnable, so this calls AWT directly rather than needing invokeLater. * fix(review): preserve current release metadata in download consent PR * fix(review): tighten executable consent cleanup and default coverage --------- Co-authored-by: Shivang * test: harden source convention scans (#508) Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> * fix(plugins): scope PluginStorageFactory.createStorage to the calling plugin's own id (#548) * fix(plugins): scope PluginStorageFactory.createStorage to the calling plugin's own id TrackingPluginContext (the only PluginContext a plugin's own code ever holds - every registration path wraps createSandboxedContext in one before calling register()) exposed the raw, unscoped PluginStorageFactory straight through from its delegate. That factory takes an arbitrary pluginId per call and the desktop implementation persists purely off that string (~/.boss/plugin-data/{pluginId}/storage.properties), with no check anywhere that it names the caller. Any installed plugin could reach another plugin's persisted data with nothing more than: context.pluginStorageFactory?.createStorage("victim-plugin-id") - read, overwrite, or clear() it. Added ScopedPluginStorageFactory, which binds createStorage to the context's own pluginId (a construction-time value the plugin does not control) and ignores whatever id the caller passes in - the same pattern DownloadCenterProviderImpl.forPlugin already uses to keep one plugin's transfer ids from colliding with or addressing another's. SandboxedPluginContext is untouched: it already passed downloadCenterProvider through unscoped in the same way, since TrackingPluginContext wraps it and is the only context a plugin ever sees - matching that existing, working precedent rather than adding a second, redundant scoping layer. Added tests/TrackingPluginContextStorageScopeTest.kt: the underlying factory is only ever asked for the context's own plugin id regardless of what a caller requests, and a write made through one plugin's storage reference never lands in another plugin's real storage. Co-Authored-By: Claude Sonnet 5 * test(plugins): model shared storage in isolation regression * test(plugins): assert scoped provider identity and document boundary --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(browser): validate co-browse control payloads before splicing into JS (#549) Validate remote co-browse control objects before embedding them in page JavaScript. Reject unquoted expressions recursively and preserve valid control messages and escaped string data. Original diagnosis, object guard and six regression tests by Antriksh. Prior review repair adds recursive token validation and four regressions; contributor replayed all three commits cleanly onto dev. All ten hosted checks and ten focused tests pass. Co-authored-by: Antriksh Co-authored-by: Claude Sonnet 5 * fix(kernel): require verified caller identity for the Supabase proxy and Download bridges (#550) * fix(kernel): require verified caller identity for the Supabase proxy and Download bridges SupabaseServiceBridge is a generic, unrestricted Postgrest proxy backed by the signed-in user's own RLS-scoped Supabase session - its rpc() reaches the same create_secret/update_secret RPCs SecretServiceBridge exists to protect, and select() can read any table RLS exposes. Neither select nor rpc checked caller identity (BossConsole#53), so any local process reaching the kernel's IPC socket had the same reach as a legitimately installed plugin, attributable to nobody. Both now require a verified process identity, matching SecretServiceBridge's established pattern, with audit logging on every call. DownloadServiceBridge.openFile/revealInFolder shell out to the OS's own "open with default application" command for any path that exists on disk, with no check that the path has anything to do with a download - before this, an unauthenticated caller on the IPC bridge could make BOSS execute any file on disk via the OS default-handler mechanism, completely bypassing BossConsole#484's executable-download-warning consent dialog (that path never goes through it at all). Every RPC on the bridge now requires a verified caller identity, and openFile/revealInFolder are additionally confined to a path this provider's own downloads list is currently tracking - canonical-path compared, so a symlink or a `..` cannot walk outside it - turning "open any file that exists" into "open a file BOSS itself downloaded". Both bridges get real gRPC-server-and-client tests (matching PluginUIServiceBridgeTest's pattern): an anonymous caller is refused with PERMISSION_DENIED on every RPC, the underlying provider is asserted never invoked on a refusal, and DownloadServiceBridge additionally covers an authenticated caller naming a path outside the tracked set. * fix(kernel): revalidate download streams and use checked paths * fix(kernel): capture stream identity and reject empty download paths * style(tests): align download regression expression wrapping * fix(kernel): clear code-quality on the download/supabase caller-identity branch detekt's default MaxLineLength (120) rejected a 128-char assertEquals in DownloadServiceBridgeTest that ktlint's wider 140-char .editorconfig limit had let through - wrap it. Also finishes the finding-6 KDoc reword the bot review flagged as ungrammatical ("this is the path does not provide" -> "this path provides no executable-consent step") and reflows that paragraph. No behavior change; addresses the last remaining code-quality blocker noted in the PR's review thread. --------- Co-authored-by: Shivang * fix(plugins): give a plugin's file panel an owner and its own directory mode (#551) (#552) * fix(plugins): give a plugin's file panel an owner and its own directory mode (#551) FilePickerProviderFactory created every panel as FileDialog(null as Frame?, ...) and showed it with a bare isVisible = true. Two consequences, and they are different failures. No owner. The panel has no window to be modal to and none to come forward over, so it can open behind the Compose window. onResult(null) then reaches the plugin looking exactly like a cancel. No directory mode. apple.awt.fileDialogForDirectories is process-wide and read by the native peer at creation, and a modal panel runs a nested event loop on the EDT that keeps dispatching. NativeFileDialogs already recorded this file as one of three that create a LOAD dialog without stating the flag, and the consequence it named: such a panel opened inside the browser's folder panel comes up as a directory chooser. That part is a defect independent of the reported symptom. NativeFileDialogs had already solved both for the browser's own panels, and its comment says the durable fix is to route every FileDialog in the tree through one helper. This extracts that helper rather than adding a third copy of it. NativeFileDialogs now delegates to it and loses its private duplicates, so the two cannot drift. The property discipline is split out as withDirectoryMode so it is testable without a display: an absent property is cleared rather than written back as "false", a present one is restored verbatim, nesting unwinds in reverse, and a throwing body still restores. Seven tests cover it. The owner selection and the panel itself are not covered and cannot be from a headless test. The catch blocks now log at error rather than warn and say that the plugin will see the failure as a user cancel, since onResult(null) is the only channel this API has and the log is the only place the difference exists. 570 tests in the browser package pass, including the existing NativeFileDialogs suite. ktlint and detekt clean. Co-Authored-By: Claude Opus 5 * fix(plugins): preserve save filters from PR 611 Consolidate Jaspreet Singh’s save-filter addition into arjun28115’s shared owned-dialog implementation. Remove comments left behind by the helper extraction. Co-authored-by: Jaspreet Singh * test(pickers): preserve pre-existing directory flag in teardown * fix(pickers): deliver one result per pick, and accept a dotted extension The two shared-provider defects handed back on #552. Neither came from the ownership change; both live in the picker this PR touches, and both are silent. 1. `onResult` was called inside the same `try` that catches picker failures, so a plugin whose callback threw received its path and then a `null` immediately after. `null` is indistinguishable from a cancel, so one pick produced two answers and the second one was wrong. Delivery now goes through `deliverOnce`, which sets its flag before invoking, so a throw from the plugin's callback cannot be retried by the catch. The two throws are told apart by when they happen: a picker failure arrives before delivery and still reports the cancel. 2. The API takes `List` and says nothing about the spelling, so plugins pass both `csv` and `.csv`, and every consumer appended its own dot. The macOS filter tested `name.endsWith(".$ext")` and Swing's FileNameExtensionFilter adds the dot itself, so `.csv` was matched as `..csv`. No file is named that, so the filter hid every valid choice instead of narrowing to them. `normalizedExtensions` strips a leading dot and drops blanks, and both platforms and both methods now route through it. Normalising rather than declaring a bare-extension contract, because the contract is not checkable at the plugin boundary and the failure it produces is a panel that lists nothing, which reads as "no matching files here". Both fixes are in Open and Save. The Save path also gains a comment saying what its filter does NOT do: a native SAVE panel narrows what is listed and does not constrain what the user types, so the returned path may still carry any extension or none. The three helpers are `internal` and pure so the rules are testable without an AWT panel on the EDT. The provider calls exactly these, so reverting a call site is a compile error rather than a silently unexercised test. 11 tests cover one delivery per pick, a throwing callback, a failure before delivery, csv/.csv, mixed case, whitespace, blank-only and empty filter lists. ktlint 1.8.0 clean; no line over 120 columns. No local test run is claimed. Co-Authored-By: Claude Opus 5 * fix(pickers): repair what CI caught on the rebased head Two failures, both mine, neither caused by the rebase. `compileTestKotlinDesktop` failed at FilePickerDeliveryTest:34. `assertEquals` takes one type parameter for both arguments, and `listOf("...")` against a `MutableList` gives it nothing to unify, so inference failed outright. The expectation is now spelled `listOf(...)`, matching the two assertions further down that already were. The `emptyList()` expectations are spelled `emptyList()` for the same reason, before they hit it too. `detekt` failed on SpreadOperator at both FileNameExtensionFilter constructions. The rule is not new and the code is not either: renaming `filters` to `extensions` moved the element out of whatever suppressed it before. Suppressed at the two functions with the reasoning written out, matching what NativeFileDialogs.onOpenFiles in this same PR already does. The spread is over a plugin's declared filter list, a handful of entries, and FileNameExtensionFilter is Java varargs with no collection overload to prefer. Worth saying plainly: a type error is something a compile would have caught and I shipped it without one. There is no working Gradle path on this machine, so ktlint is the only gate I can run locally, and it does not type-check. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang Co-authored-by: Jaspreet Singh * feat(mcp): trust a whole plugin's tools at once, not one at a time (#542) * feat(mcp): trust a whole plugin's tools at once, not one at a time Adds a "Trust This Plugin" option to the MCP tool approval dialog - a persisted, provider-wide ALLOW covering every tool a plugin contributes, not just the one call being approved. Answers the actual friction: a plugin with several mutating tools currently means approving each one individually, in the same session, one at a time. McpToolPolicyConfig gains a second rule map, providerRules, keyed by providerId rather than tool name. policyFor(toolName, providerId) now resolves both, with DENY - at either level - always winning first and unconditionally: a provider-wide DENY is not "most specific wins" and must not be overridable by a narrower per-tool ALLOW, the same posture DENY already has against session trust everywhere else in this engine. Below that: session trust, then an explicit tool-specific rule (ALLOW or ASK), then the provider's own ALLOW, then the risk-based default. providerId is an optional parameter so an existing caller that only ever checked a tool name keeps compiling. setToolPolicy, the new setProviderPolicy and revokeProviderPolicy now share one persistAndPublish helper for the persist-and-fault-report shape, so a future change to how a write failure is logged or reported cannot update one path and silently miss the others - the mistake a similar policy engine made once already by not sharing this logic. revokeProviderPolicy removes the key rather than rewriting it to some neutral value, for the same reason. McpApprovalDecision.Approved gains a trustProvider flag alongside the existing trustForSession one. Persisting it is a synchronous disk write, so it runs under withContext(Dispatchers.IO) rather than on whatever dispatcher delivered the MCP call - and a failed persist doesn't block the call the operator already approved past; it falls back to session trust for that one tool instead; a new PROVIDER_TRUST_PERSIST_FAILED ledger disposition records that the durable grant did not save. The new button sits in its own row below the main actions, not next to "Approve Once" - it is the broadest grant this dialog can make, so it should take a deliberate reach rather than risk a fast click landing on it by mistake. A caption names the scope. Revocation is a new McpProviderTrustDialog (list + per-row revoke, mirroring the shape an equivalent per-tool dialog would need) reachable from a "Trusted plugins (n)" button in the bottom bar, next to the existing session- trust revoke button - only shown once a provider is actually trusted. Test plan: McpProviderPolicyTest pins policyFor's full precedence table (provider ALLOW/DENY against tool rules, session trust, the policy-unreadable fault) and persistence (round-trips through a real file; revoke removes the key, survives reload; revoking one provider leaves another's trust untouched). McpGovernedInvocationTest adds two integration cases through the real registry: approving one tool with trustProvider lets a sibling tool from the same provider through with no second prompt and no session-trust call, and revoking provider trust makes its tools ask again. No Compose-level test for the dialogs themselves - no test infrastructure exists yet for this UI layer on this branch (no ScrimmedModalSemanticsTest-style harness), noted here rather than left unstated. Verified: full :composeApp:allTests passes except the two pre-existing, environment-specific Windows symlink-privilege failures already known from earlier this week (DesktopFileScannerTest, DiscardDownloadContainmentTest). ktlintCheck/detekt clean. * test(mcp): pin the provider-trust persist-failure fallback A failed provider-wide persist must still run the already-approved call, record PROVIDER_TRUST_PERSIST_FAILED, session-trust only that one tool, and leave the provider's other tools asking. Mirrors the blocked-write setup the per-tool failure case in McpPersistentApprovalTest uses. Agent-side regression coverage added during sweep BOSS_SWEEP_20260911_mcp-policy; not part of the author's submission. * fix(mcp): guard provider-wide trust writes against a stale approval dialog trustProvider ("Trust This Plugin") called setProviderPolicy directly, with none of the revocation/DENY/availability guards validateApproval already applies before setToolPolicy's persisted write. A revoke or DENY landing while the dialog was open (e.g. an operator resetting the tool from "Persisted MCP policies") could be silently overwritten: the call in hand was correctly withheld, but the provider-wide ALLOW still persisted, leaving every other tool that provider contributes AUTO_ALLOWED from an authorization the reset was supposed to invalidate. setProviderPolicy now takes the same preserveDeny/expectedRevocation guards setToolPolicy has, checked under the same lock, plus an optional toolName since the revocation check is inherently per-tool. The trustProvider branch in approvedAuthorization pre-checks isAvailable/revocationVersion/policyFor before writing (mirroring validateApproval exactly) and, on a false return, disambiguates a guard refusal (POLICY_DENIED, call does not run) from a genuine disk failure (PROVIDER_TRUST_PERSIST_FAILED, call still runs - the existing, deliberate fallback for that case). New regression test in McpGovernedInvocationTest reproduces the review's exact scenario (a revokePersistedPolicy landing while a prompt is open) and confirms it fails without the fix. AGENTS.md's governed-MCP section now documents provider-wide trust as its own scope, including the deliberate asymmetry between a stale-dialog refusal and a disk-failure fallback. * fix(mcp): invalidate queued grants when provider trust is reset * fix(mcp): clear code-quality on the provider-scoped-policy branch detekt flagged setProviderPolicy's preserveDeny guard as ComplexCondition (4 boolean/comparison operators inline) and a 121-char assertEquals in McpProviderRevocationTest as MaxLineLength (over detekt's default 120, though under ktlint's wider 140-char .editorconfig limit). Extract the DENY check into isProviderOrToolDenied() - same behavior, named and testable on its own - and wrap the long assertEquals. No behavior change. --------- Co-authored-by: Shivang * fix(keymap): make an explicit Ctrl chord fire on the Control key off macOS (#553) (#559) Make explicit Ctrl bindings fire on Control off macOS, with shared capture/matching rules and platform-aware chord identity for conflict detection and migration. Preserve distinct macOS modifiers and reject unsupported Super capture. Original implementation and contributor follow-up by @arjun28115. Earlier review-agent work integrated dev, rejected unsupported Super captures, and corrected comments; those repairs are separately credited in the PR description. Closes #553. * fix(chrome): restore a minimized window before focusing it (#560) (#561) Restore iconified frames before focusing through either WindowFocusManager entry point, preserving maximized state. Original shared helper and headless mask tests by @arjun28115 (#561). Independent equivalent implementation and missing-target regression by @Sahvendra7 (#580), retained through consolidation. Earlier review-agent work integrated that regression and clarified restore ordering and documentation. Closes #560. Co-authored-by: Sahvendra * fix(cli): prevent readiness queue lost commands (#633) * fix(cli): prevent readiness queue lost commands * fix(cli): close initialization queue race and guard deferred logging --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(cli): queue external command approval prompts * fix(plugin-launchpad): harden dev jar deduplication, rollback state matrix, and archive parsing * docs(launchpad): align plugin.json schema, requiredPermissions, and pre-load startup docs * style: fix ktlint import ordering in BossBottomBar.kt * fix(launchpad): address round 5 maintainer feedback for multi-window rollback and startup fallback - DevPluginReloader: track actually-modified managers during unload/install, guard untouched or unload-refusing windows from mistaken force-uninstall in rollback, collect per-window recovery errors, and ensure clean classloader state before reinstalling prior JARs. - PluginStoreSetup: add startup fallback to store build if staged dev JAR fails registration or loading in loadPersistedPlugins. - DefaultPlugin: filter out dev JARs claiming system/protected IDs before grouping in deduplicateJars to drop lone dev JARs claiming protected IDs. - Tests: drive DevPluginRollbackTest through public DevPluginReloader.reload, verify multi-window partial unload refusal rollback isolation, add startup fallback test, and add protected dev JAR deduplication test. * fix(detekt): resolve code quality violations in PluginStoreSetup and DevPluginRollbackTest * fix(launchpad): bound manifest decompression stream and expand IPC response buffer - Expand SingleInstanceManager.MAX_RESPONSE_BYTES to 1024 bytes and clamp reload error to 400 chars, avoiding buffer overflow and false timeouts on diagnostic failures. - Introduce DevPluginArtifacts.MAX_MANIFEST_BYTES (512 KB Int) and DevPluginArtifacts.readBoundedUtf8String to bound manifest decompression streams against zip-bombs and OOM. - Guard PluginValidator.readArchive with failing manifest-valid check and return nullable PluginManifest? from readManifestFromJar without throwing IllegalStateException on overflow. - Add regression tests in SingleInstanceChannelTest and PluginValidatorEvalTest for 350+ char error payloads and oversized manifest validation failure. * fix(launchpad): bound manifest decompression in DefaultPlugin and fallback on dev registration failure * fix(launchpad): fallback to standard build on external dev plugin failure * fix(launchpad): address maintainer review for staging root, system deduplication, reload mutex, and retention * refactor(launchpad): refine system plugin authoritative check, chaining in main, and session retention helper * Respect horizontal scrollers and commit trackpad navigation on release * Fix swipe cancellation, observer lifecycle and terminal delivery * Publish prior contact termination for home swipe attribution * fix(kernel): require verified caller identity for the run-configuration bridge (#634) * fix(overlays): honor RTL and fractional placement (#477) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot sw… * fix(secrets): make the secret listing RPCs fail soft on one bad row (#668) * fix(secrets): make the secret listing RPCs fail soft on one bad row BossConsole#620: get_user_secrets, search_user_secrets and get_user_secrets_with_shared each decrypt password_encrypted and recovery_codes_encrypted with a bare call to decrypt_text inside a set-returning query. decrypt_text raises on an undecryptable value - a wrong key after a partial key rotation, storage corruption, or an HMAC/padding mismatch - and one raised row aborts the whole query, so the caller sees every one of their secrets fail to load rather than just the bad one. twofa_secret already reads through safe_decrypt_twofa_secret (NULL on failure). recovery_codes_encrypted already had a safe wrapper too - safe_decrypt_recovery_codes, whose own doc comment says "used internally by get_user_secrets functions" - but whichever later migration touched these RPCs stopped calling it and fell back to raw decrypt_text, so it sat unused. password_encrypted never had a safe wrapper at all. Adds try_decrypt_text(text), mirroring safe_decrypt_recovery_codes's BEGIN/EXCEPTION WHEN OTHERS THEN RETURN NULL shape for a scalar result, locked down the same way safe_decrypt_twofa_secret is (no client role gets EXECUTE). Routes the password read in all three RPCs through it and the recovery-codes read back through the existing safe wrapper. One corrupt row now surfaces as a blanked field - password NULL, recovery_codes [] - while every other secret in the page still loads. Deliberately not named safe_decrypt_*: ops/rotate_master_encryption_key.sql's re-encrypt loop treats every function matching that name pattern as a mapped bespoke-format adapter and refuses to run if one is unmapped. try_decrypt_text wraps the same plain envelope that script already re-encrypts directly, so it must stay outside that sweep. Scoped to the three user-facing listing RPCs only; the operator-run backfill/rotation paths keep using raw decrypt_text and should still fail closed. * Fix installed-client compatibility and preserve systemic decryption failures Includes desktop regression coverage from #621 by Rushikeshiitb and review repairs by kshivang; related independent #652 work by akashjadon04 informed the review. --------- Co-authored-by: Shivang * fix(kernel): require verified caller identity for the project-data bridge (#666) * fix(kernel): require verified caller identity for the project-data bridge BossConsole#53: ProjectDataServiceBridge 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 watch every recently opened project's path (which routinely contains a username) with no credential, and could force an arbitrary window to switch its open project via selectProject. That is not only a UI surprise: a project switch publishes a ProjectChangeEvent on the application-wide event bus every installed plugin can subscribe to, so an unauthenticated caller could trigger that broadcast on demand. Applies the same fail-closed pattern already used for ActiveTabs, Download, Git, Log, PluginUI, RoleManagement, RunConfig and Supabase: every unary RPC requires AUTHENTICATED_PROCESS_ID, the watch stream captures CURRENT_IDENTITY before collecting and re-checks it on every emission so a revoked token stops a live stream, and every mutation gets an attributed audit log line. SplitView remains the one unguarded service on this server. Rewrote ProjectDataServiceBridgeTest against a real gRPC server (the existing tests called the bridge directly, which the new gating would have refused outright) - refusal-with-no-credential, authenticated mutation, revoked unary and revoked stream cases, plus the two existing routing guarantees now exercised over an authenticated channel: selectProject's UI-dispatcher confinement and watchRecentProjects reading the process-wide ProjectState singleton rather than a per-window mirror. Also folded the stale, repeatedly-drifting enumeration of which bridges fail closed out of BossIpcServer's own KDoc - it named only two of what are now nine - in favor of pointing at each bridge's own KDoc instead. * fix(review): clarify project identity guarantees and harden test lifecycle * style(test): keep filtered flow chain continuation together --------- Co-authored-by: Shivang * fix(git): make clone timeout cancellable (#660) Fixes #641. Consolidates the independently implemented clone-timeout work from #659 and #660. Abhiram Nair authored the original onExit lifecycle, deterministic process tests, real process-tree coverage and public clone cleanup/retry repairs. Antriksh independently authored the interruptible-wait alternative and real-Git acceptance tests; target preservation, ordinary failure and final-completion coverage are incorporated with explicit credit. Maintainer repairs serialize interruptible progress delivery, make callback failures advisory and preserve successful exit during delayed notification. Co-authored-by: passionate-coder26 Co-authored-by: Antriksh * fix(kernel): require verified caller identity for the split-view bridge (#672) * 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. * test(kernel): verify split-view identity coverage and clarify scope --------- Co-authored-by: Shivang * feat(mcp): let operators set tool policy proactively (#636) * feat(search): index open tabs by their URL and file path (#606) (#648) Global Search matched an open tab only against its title, and TabResult hardcoded url = null / filePath = null on every hit. A browser tab whose title never mentions its domain, or an editor tab someone wants to find by path rather than filename, could not be found at all. searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and an editor tab's EditorTabInfo.filePath, through the same substring- containment gate (proseScore) searchRecentPages already uses for a page's URL, since a fuzzy subsequence match over a whole URL or path is close to meaningless. TabResult carries the real url/filePath instead of null, and the tab row's subtitle now leads with the workspace name (disambiguating identical URLs open in different workspaces, or across windows) followed by the url/filePath/tabType, in place of the old "tabType • workspaceName" line. 11 tests cover: URL-only and file-path-only matches, both fields populated for the right tab type and left null for the other/a blank path, a short query not matching a URL by fuzzy accident, title+URL both matching taking the higher (title) score with title highlight ranges preserved, live navigation changing which URL matches (and the old URL no longer matching), directory-path matching without a subsequence false-positive, and no-open-tabs contributing nothing rather than failing. * fix(browser): replace superseded capture requests (#509) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate d… * feat(mcp): add a clickable activity log for recent tool calls (#662) * feat(search): index open tabs by their URL and file path (#606) (#648) Global Search matched an open tab only against its title, and TabResult hardcoded url = null / filePath = null on every hit. A browser tab whose title never mentions its domain, or an editor tab someone wants to find by path rather than filename, could not be found at all. searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and an editor tab's EditorTabInfo.filePath, through the same substring- containment gate (proseScore) searchRecentPages already uses for a page's URL, since a fuzzy subsequence match over a whole URL or path is close to meaningless. TabResult carries the real url/filePath instead of null, and the tab row's subtitle now leads with the workspace name (disambiguating identical URLs open in different workspaces, or across windows) followed by the url/filePath/tabType, in place of the old "tabType • workspaceName" line. 11 tests cover: URL-only and file-path-only matches, both fields populated for the right tab type and left null for the other/a blank path, a short query not matching a URL by fuzzy accident, title+URL both matching taking the higher (title) score with title highlight ranges preserved, live navigation changing which URL matches (and the old URL no longer matching), directory-path matching without a subsequence false-positive, and no-open-tabs contributing nothing rather than failing. * fix(browser): replace superseded capture requests (#509) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * V… * Fix/spotlight project session isolation (#593) * fix(overlays): honor RTL and fractional placement (#477) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * … * fix(launchpad): eager artifact retention, authoritative preflight check, and access-hidden fallback * fix(cli): report unsuccessful rollback and require reload confirmation * fix(cli): protect persisted identities and leave active staging to its writer --------- Signed-off-by: dependabot[bot] Co-authored-by: Shivang Co-authored-by: rehannayeem0786 Co-authored-by: rehannayeem0786 <128595058+rehannayeem0786@users.noreply.github.com> Co-authored-by: Antriksh1984 Co-authored-by: Shikhar Goel Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shwetas Dhake Co-authored-by: AdityaK-iiita Co-authored-by: Oleksii (Lexi) Kyrylchuk Co-authored-by: Sahvendra Co-authored-by: Aishwary Anand <201525941+Aishwary11-11@users.noreply.github.com> Co-authored-by: Aishwary Anand Co-authored-by: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Co-authored-by: ayush Co-authored-by: Aditya Mahajan Co-authored-by: Anshul Pundhir <129892939+pundhiranshul@users.noreply.github.com> Co-authored-by: Sanjay Co-authored-by: Abhiram Nair Co-authored-by: Nilesh Vasita Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: arjun28115 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Co-authored-by: Achintya Chaurasia Co-authored-by: surfthewave-commits Co-authored-by: Ayushman <139611211+InfoSage05@users.noreply.github.com> Co-authored-by: Ishaan Chowdhury Co-authored-by: Karan Raj Co-authored-by: john_cybersage Co-authored-by: aryanbajaj868 <117334799+aryanbajaj868@users.noreply.github.com> Co-authored-by: Shivanshu Co-authored-by: Patel Karma Rohit Co-authored-by: Risa Labs Co-authored-by: john k Co-authored-by: kshivang Co-authored-by: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Co-authored-by: pundhiranshul Co-authored-by: Jaspreet Singh --- .gitignore | 3 + AGENTS.md | 22 +- README.md | 7 +- composeApp/build.gradle.kts | 34 +- .../boss/components/plugin/DefaultPlugin.kt | 270 ++++- .../components/plugin/DynamicPluginManager.kt | 4 +- .../plugin/launchpad/DevPluginArtifacts.kt | 259 +++++ .../kotlin/ai/rever/boss/cli/BossCommand.kt | 15 + .../ai/rever/boss/cli/BossPluginCommand.kt | 389 +++++++ .../desktopMain/kotlin/ai/rever/boss/main.kt | 13 + .../ai/rever/boss/plugin/PluginBuildProbe.kt | 7 +- .../ai/rever/boss/plugin/PluginStoreSetup.kt | 158 ++- .../plugin/launchpad/DevPluginReloader.kt | 323 ++++++ .../boss/plugin/launchpad/PluginManifest.kt | 209 ++++ .../boss/plugin/launchpad/PluginScaffolder.kt | 734 ++++++++++++++ .../boss/plugin/launchpad/PluginValidator.kt | 635 ++++++++++++ .../ai/rever/boss/startup/CliBootstrap.kt | 2 +- .../ai/rever/boss/utils/OsOpenArguments.kt | 2 +- .../rever/boss/utils/SingleInstanceManager.kt | 172 +++- .../resources/launcher/gradle-wrapper.jar | Bin 0 -> 47505 bytes .../boss/cli/plugin/PluginEndToEndEvalTest.kt | 427 ++++++++ .../cli/plugin/PluginScaffolderEvalTest.kt | 263 +++++ .../cli/plugin/PluginValidatorEvalTest.kt | 523 ++++++++++ .../plugin/launchpad/DevPluginReloaderTest.kt | 193 ++++ .../plugin/launchpad/DevPluginRollbackTest.kt | 947 ++++++++++++++++++ .../plugin/launchpad/PluginManifestTest.kt | 432 ++++++++ .../rever/boss/utils/DevReloadResponseTest.kt | 70 ++ .../boss/utils/SingleInstanceChannelTest.kt | 31 + docs/CLI.md | 30 +- docs/PLUGIN_LAUNCHPAD.md | 201 ++++ scripts/boss | 83 +- scripts/boss.bat | 33 +- scripts/boss.ps1 | 37 +- scripts/test/test-headless-cli.sh | 0 34 files changed, 6398 insertions(+), 130 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginArtifacts.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossPluginCommand.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloader.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginManifest.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginScaffolder.kt create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginValidator.kt create mode 100644 composeApp/src/desktopMain/resources/launcher/gradle-wrapper.jar create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginEndToEndEvalTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginScaffolderEvalTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginValidatorEvalTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloaderTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginRollbackTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/PluginManifestTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/DevReloadResponseTest.kt create mode 100644 docs/PLUGIN_LAUNCHPAD.md mode change 100644 => 100755 scripts/test/test-headless-cli.sh diff --git a/.gitignore b/.gitignore index 7eaedcd545..4eea5f6a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ captures *.zip *.msi *.jar +!gradle/wrapper/gradle-wrapper.jar +!composeApp/src/desktopMain/resources/launcher/gradle-wrapper.jar + distribution-final/ distribution/ BOSS-Standalone-Windows/ diff --git a/AGENTS.md b/AGENTS.md index dbfdc1013e..21fa8049c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2038,9 +2038,6 @@ workspace by selecting the tools you need." Tools install app-wide, not into a S - [Role Creation](docs/ROLE_CREATION_GUIDE.md) - Creating and managing roles - [Windows Deep Link](docs/WINDOWS_DEEP_LINK_SETUP.md) - Windows protocol handler setup - [Release Rebuild](docs/RELEASE_REBUILD_GUIDE.md) - Re-running release builds - - - ### Governed MCP invocation (#371) The host policy applies to registry invocation; it does not isolate installed JVM @@ -2074,6 +2071,7 @@ Provider trust also covers tools added by later versions and replacement plugins that provider id. Already queued sibling prompts still ask. Explicit tool ASK rules still override provider ALLOW. The Trusted plugins UI lists ALLOW rules only; hand-edited provider DENY rules currently require policy-file editing to remove. + Preserve a backup before manual recovery of a damaged policy; the fault flow withholds all tools until recovery. No automatic quarantine UI is provided. Ledger redaction is bounded and best effort, not a guarantee for secrets @@ -2159,3 +2157,21 @@ snapshots. Current saved rules (including ASK/default) are visible in expanded rows, and the summary distinguishes rules being replaced from new denials. Global None is a distinct deny-all preset, not Custom; Edit is the label at both levels. Search by plugin display name also matches its saved tool rules. + +### Plugin Dev Staging & Launchpad Invariants + +- **Protected Plugins Overrule Dev JARs Unconditionally**: + When deduplicating or resolving dev vs. standard plugins, `isSystemPlugin` and `requiresRestartInsteadOfHotReload` plugins MUST NEVER be superseded by a dev JAR, regardless of file modification timestamps (`lastModified`). Never rely solely on additive bonuses (`versionBonus + lastModified`) without penalizing or filtering dev JARs on protected identities. + +- **Reload Forward & Rollback State Completeness**: + Hot-reload must support both active (`LOADED`) and inactive (`DISABLED`) plugins symmetrically: + - If a plugin was disabled prior to reload (`wasEnabled = false`), forward reload must install with `enabled = false` and accept `state == DISABLED` as an expected successful outcome. + - If the active user lacks RBAC permissions, forward reload must accept `state == DISABLED && !canAccess(manifest)` as a valid outcome. + - Rollback must restore `wasEnabled = false` and reinstall `v1.jar` in `DISABLED` state without uninstallation. + +- **Archive Traversal & Stream Bounds**: + Never trust `ZipEntry.size` alone for decompression limits, as `size == -1` in streaming ZIPs. Always enforce hard byte caps on the incoming `InputStream` (e.g. `readNBytes(MAX + 1)` or explicit counter bounds) to prevent heap exhaustion. + +- **Test Veracity Rules**: + - In deduplication tests, ALWAYS test with dev JAR `lastModified` strictly greater than standard JAR `lastModified` to mirror real-world compiler outputs. + - Test the public reload pipeline (`DevPluginReloader.reload`) end-to-end rather than calling internal rollback helpers in isolation. diff --git a/README.md b/README.md index 45fafbf4f7..36cdc4f0cd 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Absolute scores were depressed by heavy co-tenancy - **800–1300% ambient CPU** - [Case study: the DNA Origami Inventor](#case-study-the-dna-origami-inventor) - [Design System](#design-system) - [Development](#development) -- [CLI](#cli) · [CI/CD](#cicd) · [Documentation](#documentation) · [Open source & ecosystem](#open-source--ecosystem) +- [CLI & Agent Harness](#cli--agent-harness) · [CI/CD](#cicd) · [Documentation](#documentation) · [Open source & ecosystem](#open-source--ecosystem) --- @@ -410,9 +410,13 @@ boss status --json # Query running application status boss mcp list --filter browser # Discover registered MCP tools boss mcp describe # Fetch tool schema on demand boss mcp invoke --args ... # Execute tool headlessly via authenticated IPC +boss plugin init # Scaffold a new plugin Gradle project +boss plugin validate # Validate plugin manifest and bytecode +boss plugin link # Stage plugin into BOSS_HOME with hot-reload ``` See [docs/CLI.md](docs/CLI.md) for full options, exit codes, stream guarantees, and agent integration recipes. +See [docs/PLUGIN_LAUNCHPAD.md](docs/PLUGIN_LAUNCHPAD.md) for plugin authoring, templates, and hot-reload workflows. **Installation:** Toolbox / Tools → Install BOSS CLI (or via Homebrew / installers). @@ -421,6 +425,7 @@ See [docs/CLI.md](docs/CLI.md) for full options, exit codes, stream guarantees, ## Documentation - [Command Line Interface (CLI)](docs/CLI.md) - CLI Agent Harness, `boss status`, `boss mcp`, and agent scripting +- [Plugin Launchpad](docs/PLUGIN_LAUNCHPAD.md) - Developer CLI (`boss plugin init`, `validate`, `link`) & hot-reload - [Core Subsystems](docs/SUBSYSTEMS.md) - Auth, UI, keyboard shortcuts, threading - [Design System](docs/DESIGN_SYSTEM.md) - "Operator's Console" tokens, themes, and the live styleguide - [BossEditor Module](docs/BOSSEDITOR.md) - LSP, PSI, editor features diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index fee25d65f2..e57e330fcc 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -73,6 +73,26 @@ abstract class JxBrowserVersionValueSource : ValueSource { + interface Parameters : ValueSourceParameters { + val tomlFile: RegularFileProperty + } + + override fun obtain(): String { + val file = parameters.tomlFile.get().asFile + if (!file.exists()) { + throw GradleException("libs.versions.toml not found at ${file.absolutePath}") + } + val content = file.readText() + return Regex("""boss-plugin-api\s*=\s*"([^"]+)"""") + .find(content) + ?.groupValues + ?.get(1) + ?: throw GradleException("Could not find boss-plugin-api version in libs.versions.toml") + } +} + // Load version from properties file using configuration-cache-compatible providers val versionPropsFile = layout.projectDirectory.file("../version.properties") val versionPropsProvider = @@ -159,6 +179,13 @@ val jxBrowserVersion = project.findProperty("jxBrowserVersion")?.toString() ?: jxBrowserVersionProvider.get() +// Configuration-cache-compatible provider for Plugin API contract version from libs.versions.toml +val pluginApiVersionProvider = + providers.of(PluginApiVersionValueSource::class.java) { + parameters.tomlFile.set(libsVersionsFile) + } +val pluginApiVersion = pluginApiVersionProvider.get() + // local.properties (git-ignored) as a lazy, configuration-cache-tracked input. // Absent file → absent provider; callers must getOrElse/orNull. val localPropertiesProvider: Provider = @@ -239,9 +266,10 @@ val generateVersionConstants = val minorProvider = propsProvider.map { it.getProperty("app.version.minor", "8") } val patchProvider = propsProvider.map { it.getProperty("app.version.patch", "0") } val prereleaseProvider = propsProvider.map { it.getProperty("app.prerelease.suffix", "") } + val pluginApiProvider = pluginApiVersionProvider val jxVersionProvider = jxBrowserVersionProvider - // Track libs.versions.toml as an input for JxBrowser version + // Track libs.versions.toml as an input for JxBrowser and Plugin API versions inputs.file(libsVersionsFile) inputs.file(versionPropsFile) @@ -256,6 +284,7 @@ val generateVersionConstants = val minor = minorProvider.get() val patch = patchProvider.get() val prerelease = prereleaseProvider.get().takeIf { it.isNotBlank() } + val pluginApiVer = pluginApiProvider.get() val jxVersion = jxVersionProvider.get() // Generate PRERELEASE constant as nullable String @@ -281,6 +310,9 @@ val generateVersionConstants = | | /** JxBrowser version from gradle/libs.versions.toml */ | const val JXBROWSER_VERSION = "$jxVersion" + | + | /** Plugin API contract version from gradle/libs.versions.toml */ + | const val PLUGIN_API_VERSION = "$pluginApiVer" |} | """.trimMargin(), diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt index 1822a07d5a..8365e23a36 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt @@ -67,6 +67,7 @@ import ai.rever.boss.plugin.api.NotificationProvider import ai.rever.boss.plugin.api.PanelRegistry import ai.rever.boss.plugin.api.PluginContext import ai.rever.boss.plugin.api.PluginSandboxRef +import ai.rever.boss.plugin.api.PluginState import ai.rever.boss.plugin.api.PluginStorageFactory import ai.rever.boss.plugin.api.PluginStoreApiKeyProvider import ai.rever.boss.plugin.api.ProjectData @@ -84,6 +85,7 @@ import ai.rever.boss.plugin.api.UserManagementProvider import ai.rever.boss.plugin.api.WorkspaceDataProvider import ai.rever.boss.plugin.api.ZoomSettingsProvider import ai.rever.boss.plugin.browser.BrowserService +import ai.rever.boss.plugin.launchpad.DevPluginArtifacts import ai.rever.boss.plugin.loader.PluginLoadException import ai.rever.boss.plugin.pathutils.BossDirectories import ai.rever.boss.plugin.sandbox.PluginSandboxManager @@ -182,6 +184,77 @@ class DefaultPlugin( var loadPersistedPluginsInternal: suspend (DynamicPluginManager) -> Unit = { _ -> // Default no-op - platform-specific code should set this } + + /** + * Authoritative check for protected system plugin IDs when no manager has loaded them yet. + * Platform-specific desktop initialization sets this to SystemPluginManifestService / PluginStoreSetup. + */ + @Volatile + var isAuthoritativeSystemPlugin: (String) -> Boolean = { pluginId -> + pluginId == MicrokernelRuntime.PLUGIN_ID || + pluginId.startsWith("ai.rever.boss.system.") || + pluginId == "ai.rever.boss.plugin.api" + } + + internal fun findActiveDevJars(devDir: File): List = + DevPluginArtifacts + .findAllActiveDevJars(devDir, deepValidate = true) + + internal fun extractPluginId(jarFile: File): String = + readManifestIdFromJar(jarFile) + ?: jarFile.nameWithoutExtension + + private fun readManifestIdFromJar(jarFile: File): String? = + if (!jarFile.exists() || jarFile.length() == 0L) { + null + } else { + readManifestText(jarFile)?.let(DevPluginArtifacts::extractPluginIdFromManifestText) + } + + private fun readManifestText(jarFile: File): String? = + try { + java.util.jar.JarFile(jarFile).use { jar -> + readEntryText(jar) + } + } catch (_: Exception) { + null + } + + private fun readEntryText(jar: java.util.jar.JarFile): String? { + val entry = + jar.getJarEntry("META-INF/boss-plugin/plugin.json") + ?: jar.getJarEntry("plugin.json") + ?: return null + return jar.getInputStream(entry).use { stream -> + DevPluginArtifacts.readBoundedUtf8String(stream) + } + } + + internal fun deduplicateJars( + jars: List, + isProtectedPredicate: (String) -> Boolean = { false }, + ): List = + jars + .filterNot { file -> + DevPluginArtifacts.isDevPluginJar(file) && + isProtectedPredicate(extractPluginId(file)) + }.groupBy { extractPluginId(it) } + .mapValues { (pluginId, group) -> + group.maxByOrNull { file -> + val isDev = + DevPluginArtifacts + .isDevPluginJar(file) + val isProtected = isProtectedPredicate(pluginId) + val versionBonus = + when { + isProtected && isDev -> -10_000_000_000_000L + isDev -> 10_000_000_000_000L + else -> 0L + } + versionBonus + file.lastModified() + } ?: group.first() + }.values + .toList() } private val logger = BossLogger.forComponent("DefaultPlugin") @@ -1180,13 +1253,21 @@ class DefaultPlugin( // updater can replace jars while startup is in flight — a listing // captured at init would try already-deleted files and never see // freshly downloaded ones. - val jarFiles = + val standardJars = pluginDir.listFiles { file -> file.isFile && file.extension == "jar" && // Skip microkernel runtime — it's a classpath dependency for OOP plugins, not a loadable plugin !file.name.startsWith(MicrokernelRuntime.ARTIFACT_PREFIX) } ?: emptyArray() + val devJars = findActiveDevJars(DevPluginArtifacts.stagingRoot()) + val jarFiles = + deduplicateJars(standardJars.toList() + devJars) { pluginId -> + manager.isSystemPlugin(pluginId) || + isAuthoritativeSystemPlugin(pluginId) || + HotReloadPolicy.requiresRestartInsteadOfHotReload(pluginId) + } + if (jarFiles.isEmpty()) { logger.debug( LogCategory.SYSTEM, @@ -1218,64 +1299,149 @@ class DefaultPlugin( .map { it.jarPath } .toSet() - for (jarFile in jarFiles) { - if (jarFile.absolutePath in trackedJarPaths) continue - try { - logger.info( - LogCategory.SYSTEM, - "Installing external plugin", - mapOf( - "file" to jarFile.name, - ), - ) - - val result = manager.installPlugin(jarFile.absolutePath) + val standardJarsById = standardJars.associateBy { extractPluginId(it) } - if (result.isSuccess) { - val info = result.getOrThrow() - logger.info( - LogCategory.SYSTEM, - "External plugin loaded successfully", - mapOf( - "pluginId" to info.manifest.pluginId, - "version" to info.manifest.version, - "displayName" to info.manifest.displayName, - ), - ) - } else if (result.exceptionOrNull()?.message?.startsWith(PluginLoadException.ALREADY_LOADED_PREFIX) == true) { - // A second jar for a plugin that's already running — a - // stale old version left in the directory, not a failure. - logger.info( - LogCategory.SYSTEM, - "Skipping duplicate jar for already-loaded plugin", - mapOf( - "file" to jarFile.name, - ), - ) + for (jarFile in jarFiles) { + val fallbackJar = + if (DevPluginArtifacts.isDevPluginJar(jarFile)) { + standardJarsById[extractPluginId(jarFile)]?.takeIf { it != jarFile } } else { - logger.error( - LogCategory.SYSTEM, - "Failed to load external plugin", - mapOf( - "file" to jarFile.name, - "error" to (result.exceptionOrNull()?.message ?: "unknown"), - ), - ) + null } - } catch (e: Exception) { - logger.error( - LogCategory.SYSTEM, - "Exception loading external plugin", - mapOf( - "file" to jarFile.name, - ), - e, - ) - } + installSingleExternalPlugin(manager, jarFile, trackedJarPaths, fallbackJar) } } } + internal suspend fun installSingleExternalPlugin( + manager: DynamicPluginManager, + jarFile: File, + trackedJarPaths: Set, + fallbackStandardJar: File? = null, + ) { + if (jarFile.absolutePath in trackedJarPaths) return + try { + logger.info( + LogCategory.SYSTEM, + "Installing external plugin", + mapOf("file" to jarFile.name), + ) + + val result = manager.installPlugin(jarFile.absolutePath) + handleExternalPluginInstallResult(manager, jarFile, result, fallbackStandardJar) + } catch (e: Exception) { + logger.error( + LogCategory.SYSTEM, + "Exception loading external plugin", + mapOf("file" to jarFile.name), + e, + ) + } + } + + private suspend fun handleExternalPluginInstallResult( + manager: DynamicPluginManager, + jarFile: File, + result: Result, + fallbackStandardJar: File?, + ) { + val loadedInfo = result.getOrNull() + val isBrokenDev = + DevPluginArtifacts.isDevPluginJar(jarFile) && + loadedInfo?.state == PluginState.DISABLED && + manager.canAccess(loadedInfo.manifest) + val isAlreadyLoaded = + result.exceptionOrNull()?.message?.startsWith(PluginLoadException.ALREADY_LOADED_PREFIX) == true + + when { + result.isSuccess && !isBrokenDev -> { + val info = result.getOrThrow() + logger.info( + LogCategory.SYSTEM, + "External plugin loaded successfully", + mapOf( + "pluginId" to info.manifest.pluginId, + "version" to info.manifest.version, + "displayName" to info.manifest.displayName, + ), + ) + } + + isAlreadyLoaded -> { + logger.info( + LogCategory.SYSTEM, + "Skipping duplicate jar for already-loaded plugin", + mapOf("file" to jarFile.name), + ) + } + + attemptFallbackOnDevFailure(manager, jarFile, fallbackStandardJar, result) -> { + // Fallback attempt was executed and logged + } + + else -> { + logger.error( + LogCategory.SYSTEM, + "Failed to load external plugin", + mapOf( + "file" to jarFile.name, + "error" to (result.exceptionOrNull()?.message ?: "Dev plugin loaded in DISABLED state"), + ), + ) + } + } + } + + private suspend fun attemptFallbackOnDevFailure( + manager: DynamicPluginManager, + jarFile: File, + fallbackStandardJar: File?, + devResult: Result, + ): Boolean = + if (!DevPluginArtifacts.isDevPluginJar(jarFile) || + fallbackStandardJar == null || + !fallbackStandardJar.exists() + ) { + false + } else { + val devError = + devResult.exceptionOrNull()?.message + ?: devResult.getOrNull()?.errorMessage + ?: "Dev plugin loaded in DISABLED state" + logger.warn( + LogCategory.SYSTEM, + "External dev plugin failed to load; falling back to standard build", + mapOf( + "devJar" to jarFile.name, + "fallback" to fallbackStandardJar.name, + "error" to devError, + ), + ) + val fallbackResult = manager.installPlugin(fallbackStandardJar.absolutePath) + if (fallbackResult.isSuccess) { + val info = fallbackResult.getOrThrow() + logger.info( + LogCategory.SYSTEM, + "External standard plugin loaded successfully as fallback", + mapOf( + "pluginId" to info.manifest.pluginId, + "version" to info.manifest.version, + "displayName" to info.manifest.displayName, + ), + ) + } else { + logger.error( + LogCategory.SYSTEM, + "Fallback to standard build also failed for external plugin", + mapOf( + "file" to fallbackStandardJar.name, + "error" to (fallbackResult.exceptionOrNull()?.message ?: "unknown"), + ), + ) + } + true + } + // ============================================================ // REMOVED BUNDLED PLUGINS // The following registration methods have been removed as these diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DynamicPluginManager.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DynamicPluginManager.kt index 47349d99c3..531c100bc5 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DynamicPluginManager.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DynamicPluginManager.kt @@ -363,7 +363,7 @@ class DynamicPluginManager( */ private val swapScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private fun activeManagers(): List { + internal fun activeManagers(): List { liveManagers.removeIf { it.get() == null } return liveManagers.mapNotNull { it.get() } } @@ -803,7 +803,7 @@ class DynamicPluginManager( * plugin's [PluginManifest.requiredPermissions]. An empty list (legacy * plugins) means "available to any authenticated user". */ - private fun canAccess(manifest: PluginManifest): Boolean = + internal fun canAccess(manifest: PluginManifest): Boolean = pluginAccessAllowed( isAdmin = _isAdmin.value, userPermissions = _userPermissions.value, diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginArtifacts.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginArtifacts.kt new file mode 100644 index 0000000000..d48fe33c11 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginArtifacts.kt @@ -0,0 +1,259 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.plugin.pathutils.BossDirectories +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.nio.file.Path + +/** + * Manages staging directories, version history, and dev JAR discovery for BossConsole plugins. + */ +@Suppress("ReturnCount", "TooGenericExceptionCaught", "TooManyFunctions") +object DevPluginArtifacts { + private val logger = BossLogger.forComponent("DevPluginArtifacts") + private val manifestJson = Json { ignoreUnknownKeys = true } + + const val DEFAULT_MAX_VERSIONS_TO_KEEP: Int = 3 + const val MAX_MANIFEST_BYTES: Int = 512 * 1024 + + /** Staging root directory override for isolated unit and integration tests. */ + @Volatile + internal var stagingRootOverride: File? = null + + /** + * Reads up to [maxBytes] from [stream] as UTF-8. + * Returns null if the stream exceeds [maxBytes] or cannot be read. + */ + internal fun readBoundedUtf8String( + stream: java.io.InputStream, + maxBytes: Int = MAX_MANIFEST_BYTES, + ): String? = + try { + stream.use { s -> + val bytes = s.readNBytes(maxBytes + 1) + if (bytes.size > maxBytes) null else bytes.toString(Charsets.UTF_8) + } + } catch (_: Exception) { + null + } + + /** + * Resolves the root staging directory for dev plugins (~/.boss/plugins/dev or ~/.boss_debug/plugins/dev). + */ + fun stagingRoot(): File = stagingRootOverride ?: BossDirectories.resolve("plugins/dev") + + /** + * Extracts the top-level plugin ID from a plugin.json manifest text. + * Accurately inspects root keys ("pluginId", "id") and ignores nested objects + * like dependencies or MCP tools that may declare an "id" field. + */ + fun extractPluginIdFromManifestText(text: String): String? = + runCatching { + val root = manifestJson.parseToJsonElement(text).jsonObject + root["pluginId"]?.jsonPrimitive?.contentOrNull + ?: root["id"]?.jsonPrimitive?.contentOrNull + }.getOrNull() + + /** + * Resolves whether [jarPath] points to a version-rotated dev JAR under the staging root. + */ + fun isDevPluginJar(jarFile: File): Boolean = isDevPluginPath(jarFile.toPath()) + + /** + * Resolves whether [jarPath] points to a version-rotated dev JAR under the staging root. + */ + fun isDevPluginPath( + jarPath: Path, + devRoot: File = stagingRoot(), + ): Boolean { + val stagingDir = devRoot.toPath().toAbsolutePath().normalize() + val candidate = jarPath.toAbsolutePath().normalize() + val parent = candidate.parent ?: return false + return candidate.startsWith(stagingDir) && parent.fileName?.toString()?.startsWith("v") == true + } + + /** + * Deeply validates a candidate dev plugin JAR to ensure: + * 1. It is a readable ZIP/JAR archive. + * 2. It contains META-INF/boss-plugin/plugin.json. + * 3. (Optional) The manifest's pluginId matches [expectedPluginId]. + */ + fun isValidDevJar( + jarFile: File, + expectedPluginId: String? = null, + ): Boolean { + if (!isEligibleJarFile(jarFile)) return false + val manifestText = readManifestText(jarFile) ?: return false + if (expectedPluginId == null) return true + return extractPluginIdFromManifestText(manifestText) == expectedPluginId + } + + private fun isEligibleJarFile(file: File): Boolean { + if (!file.isFile || file.length() <= 0) return false + return file.extension == "jar" && !file.name.endsWith(".part") + } + + private fun readManifestText(jarFile: File): String? = + try { + java.util.jar.JarFile(jarFile).use { jar -> + val entry = jar.getJarEntry("META-INF/boss-plugin/plugin.json") ?: return null + jar.getInputStream(entry).use { readBoundedUtf8String(it) } + } + } catch (_: Exception) { + null + } + + /** + * Finds the active dev JAR for [pluginId] by inspecting version directories in descending order. + * Skips empty or corrupt version directories and returns the latest directory containing a valid JAR. + * When [deepValidate] is true, candidates must also pass [isValidDevJar]. + */ + fun findActiveDevJar( + pluginId: String, + devRoot: File = stagingRoot(), + deepValidate: Boolean = false, + ): File? { + val pluginDir = File(devRoot, pluginId) + if (!pluginDir.exists() || !pluginDir.isDirectory) return null + + val versionDirs = + pluginDir.listFiles { file -> file.isDirectory && file.name.startsWith("v") } + ?: return null + + val sortedVersionDirs = + versionDirs.sortedWith( + compareByDescending { dir -> + dir.name.removePrefix("v").toLongOrNull() ?: dir.lastModified() + }, + ) + + for (vDir in sortedVersionDirs) { + val candidate = + vDir + .listFiles { file -> + file.isFile && file.extension == "jar" && !file.name.endsWith(".part") && file.length() > 0 + }?.firstOrNull() + if (candidate != null) { + if (!deepValidate || isValidDevJar(candidate, pluginId)) { + return candidate + } + } + } + return null + } + + /** + * Resolves the staging directory for a specific [pluginId] under [devRoot]. + */ + fun pluginDevDir( + pluginId: String, + devRoot: File = stagingRoot(), + ): File = File(devRoot, pluginId) + + /** + * Discovers all active dev plugin JARs under [devRoot]. + * When [deepValidate] is true, only JARs passing [isValidDevJar] are returned. + */ + fun findAllActiveDevJars( + devRoot: File = stagingRoot(), + deepValidate: Boolean = false, + ): List { + if (!devRoot.exists() || !devRoot.isDirectory) return emptyList() + + val activeJars = mutableListOf() + val pluginDirs = devRoot.listFiles { file -> file.isDirectory } ?: emptyArray() + for (pDir in pluginDirs) { + val jar = findActiveDevJar(pDir.name, devRoot, deepValidate) + if (jar != null) { + activeJars.add(jar) + } + } + return activeJars + } + + /** + * Safely prunes older version directories, retaining at most [maxVersionsToKeep] valid builds. + * Never deletes the currently active or staged version, and strictly retains any version directory + * whose JAR path is listed in [activeJarPaths] (e.g. actively referenced by any running window). + */ + fun pruneStagingHistory( + pluginDevDir: File, + maxVersionsToKeep: Int = DEFAULT_MAX_VERSIONS_TO_KEEP, + activeJarPaths: Set = emptySet(), + ) { + if (!pluginDevDir.exists() || !pluginDevDir.isDirectory) return + + val allVersionDirs = + pluginDevDir.listFiles { file -> file.isDirectory && file.name.startsWith("v") } + ?: return + + val validDirs = + allVersionDirs.filter { dir -> + val jars = + dir.listFiles { file -> + file.isFile && file.extension == "jar" && !file.name.endsWith(".part") && file.length() > 0 + } + !jars.isNullOrEmpty() + } + + val sortedDirs = + validDirs.sortedWith( + compareByDescending { dir -> + dir.name.removePrefix("v").toLongOrNull() ?: dir.lastModified() + }, + ) + + if (sortedDirs.size <= maxVersionsToKeep) return + + val normalizedActivePaths = + activeJarPaths + .mapNotNull { path -> + runCatching { File(path).canonicalPath }.getOrNull() ?: File(path).absolutePath + }.toSet() + + val candidatesToPrune = sortedDirs.drop(maxVersionsToKeep) + val dirsToPrune = + candidatesToPrune.filterNot { dir -> + isDirReferencedByActiveJars(dir, activeJarPaths, normalizedActivePaths) + } + + pruneOldVersionDirectories(dirsToPrune) + } + + private fun isDirReferencedByActiveJars( + dir: File, + activeJarPaths: Set, + normalizedActivePaths: Set, + ): Boolean { + val jars = dir.listFiles { file -> file.isFile && file.extension == "jar" } ?: return false + return jars.any { jar -> + val abs = jar.absolutePath + val canonical = runCatching { jar.canonicalPath }.getOrNull() ?: abs + abs in activeJarPaths || canonical in normalizedActivePaths + } + } + + private fun pruneOldVersionDirectories(dirsToPrune: List) { + for (dir in dirsToPrune) { + try { + dir.deleteRecursively() + logger.debug( + LogCategory.SYSTEM, + "Pruned old dev plugin staging directory", + mapOf("dir" to dir.absolutePath), + ) + } catch (e: Exception) { + logger.warn( + LogCategory.SYSTEM, + "Failed to delete old dev plugin staging directory", + mapOf("dir" to dir.absolutePath, "error" to (e.message ?: "unknown")), + ) + } + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt index af4a119781..ed8ea1d222 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossCommand.kt @@ -555,6 +555,16 @@ class BossCompletionCommand : CliktCommand(name = "completion") { } } +/** + * Plugin developer tools and lifecycle management. + * Subcommands (init, validate, link) provide scaffolding, validation, and hot-linking. + */ +class BossPluginCommand : CliktCommand(name = "plugin") { + override fun help(context: Context) = "Plugin developer tools and lifecycle management" + + override fun run() = Unit +} + /** * Configures Clikt command structure. */ @@ -569,6 +579,11 @@ fun createBossCLI(): BossCommand = BossDoctorCommand(), BossMcpCommand(), BossCompletionCommand(), + BossPluginCommand().subcommands( + BossPluginInitCommand(), + BossPluginValidateCommand(), + BossPluginLinkCommand(), + ), ) /** Configure before any BOSS logger initializes, keeping machine-readable stdout clean. */ diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossPluginCommand.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossPluginCommand.kt new file mode 100644 index 0000000000..943da404a4 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/BossPluginCommand.kt @@ -0,0 +1,389 @@ +package ai.rever.boss.cli + +import ai.rever.boss.plugin.launchpad.DevPluginArtifacts +import ai.rever.boss.plugin.launchpad.PluginManifest +import ai.rever.boss.plugin.launchpad.PluginScaffolder +import ai.rever.boss.plugin.launchpad.PluginValidator +import ai.rever.boss.plugin.launchpad.launchpadJson +import ai.rever.boss.utils.ReloadResult +import ai.rever.boss.utils.SingleInstanceManager +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.default +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.path +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption + +/** + * Scaffolds a new third-party plugin project. + * Usage: boss plugin init [--template ] [--dir ] [--force] [--json] + */ +@Suppress("TooGenericExceptionCaught") +class BossPluginInitCommand : CliktCommand(name = "init") { + override fun help(context: Context) = "Scaffolds a new plugin project" + + private val logger = BossLogger.forComponent("BossPluginInitCommand") + + val name by argument(help = "Plugin name (e.g. 'my-tools')") + val template by option( + "-t", + "--template", + help = "Plugin template: mcp-tool, ui-panel, background-service, full (default: mcp-tool)", + ).default("mcp-tool") + val dir by option("-d", "--dir", help = "Target directory path") + val force by option("-f", "--force", help = "Overwrite non-empty target directory").flag(default = false) + val json by option("--json", help = "Output machine-readable JSON").flag(default = false) + + override fun run() { + val targetDir = if (dir != null) File(dir!!).absoluteFile else File(name).absoluteFile + + try { + val result = + PluginScaffolder.scaffold( + name = name, + templateName = template, + targetDir = targetDir, + force = force, + ) + + if (json) { + val payload = + buildJsonObject { + put("status", "scaffolded") + put("pluginId", result.pluginId) + put("template", template) + put("targetDir", result.targetDirectory.absolutePath.replace('\\', '/')) + put( + "files", + buildJsonArray { + result.filesCreated.forEach { file -> + val relPath = file.relativeTo(result.targetDirectory).path.replace('\\', '/') + add(relPath) + } + }, + ) + } + echo(payload.toString()) + } else { + echo( + "[✓] Plugin '${result.pluginId}' scaffolded successfully at " + + result.targetDirectory.absolutePath, + ) + echo("Template: $template") + echo("Generated files:") + result.filesCreated.forEach { file -> + val relPath = file.relativeTo(result.targetDirectory).path.replace('\\', '/') + echo(" - $relPath") + } + } + } catch (e: Exception) { + val errorMsg = e.message ?: "Failed to scaffold plugin" + logger.error(LogCategory.SYSTEM, "Failed to scaffold plugin: $errorMsg", error = e) + if (json) { + val errPayload = + buildJsonObject { + put("status", "error") + put("error", errorMsg) + } + echo(errPayload.toString()) + } else { + echo("Error: $errorMsg", err = true) + } + throw ProgramResult(1) + } + } +} + +/** + * Validates a plugin directory or packaged archive (.jar / .zip). + * Usage: boss plugin validate [] [--json] + */ +class BossPluginValidateCommand : CliktCommand(name = "validate") { + override fun help(context: Context) = "Validates a plugin manifest and bytecode structure" + + private val logger = BossLogger.forComponent("BossPluginValidateCommand") + + val path by argument(help = "Path to plugin directory or JAR/ZIP archive") + .path(canBeDir = true, canBeFile = true) + .default(Paths.get(".")) + val json by option("--json", help = "Output machine-readable JSON").flag(default = false) + + override fun run() { + val target = path.toFile().absoluteFile + val result = PluginValidator.validate(target) + val report = PluginValidator.toReport(target.absolutePath, result) + + if (json) { + val jsonText = launchpadJson.encodeToString(report) + echo(jsonText) + if (!report.success) { + throw ProgramResult(1) + } + } else { + echo("Validating plugin at ${target.absolutePath}...") + result.checks.forEach { check -> + val mark = if (check.passed) "[✓]" else "[✗]" + echo("$mark ${check.name}: ${check.message}") + } + if (report.success) { + echo("\n[✓] Validation passed (${report.checksPassed}/${report.totalChecks} checks passed)") + } else { + logger.warn( + LogCategory.SYSTEM, + "Validation failed with ${report.failures.size} errors at ${target.absolutePath}", + ) + echo("\nValidation failed with ${report.failures.size} errors:", err = true) + report.failures.forEach { echo(" [✗] ${it.checkName}: ${it.message}", err = true) } + throw ProgramResult(1) + } + } + } +} + +/** + * Stages and links a plugin to BossConsole and dispatches a live hot-reload signal. + * Usage: boss plugin link [] [--json] + */ +@Suppress("LongMethod", "CyclomaticComplexMethod", "NestedBlockDepth", "TooGenericExceptionCaught") +class BossPluginLinkCommand : CliktCommand(name = "link") { + override fun help(context: Context) = "Links a local plugin into BossConsole development environment" + + private val logger = BossLogger.forComponent("BossPluginLinkCommand") + + val path by argument(help = "Path to plugin directory or JAR/ZIP archive") + .path(canBeDir = true, canBeFile = true) + .default(Paths.get(".")) + val json by option("--json", help = "Output machine-readable JSON").flag(default = false) + + override fun run() { + val inputPath = path.toAbsolutePath() + + if (!Files.exists(inputPath)) { + val msg = "Target path does not exist: $inputPath" + logger.error(LogCategory.SYSTEM, msg) + if (json) { + echo( + buildJsonObject { + put("status", "error") + put("error", msg) + }.toString(), + ) + } else { + echo("Error: $msg", err = true) + } + throw ProgramResult(1) + } + + val targetJarPath = + try { + PluginValidator.resolveStagingTarget(inputPath) + } catch (e: Exception) { + val msg = e.message ?: "Failed to resolve staging target JAR" + logger.error(LogCategory.SYSTEM, msg, error = e) + if (json) { + echo( + buildJsonObject { + put("status", "error") + put("error", msg) + }.toString(), + ) + } else { + echo("Error: $msg", err = true) + } + throw ProgramResult(1) + } + + val targetJarFile = targetJarPath.toFile() + val validation = PluginValidator.validate(targetJarFile) + if (!validation.isValid) { + val errMsg = "Cannot link invalid plugin at ${targetJarFile.absolutePath}" + logger.error(LogCategory.SYSTEM, errMsg) + val report = PluginValidator.toReport(targetJarFile.absolutePath, validation) + if (json) { + echo(launchpadJson.encodeToString(report)) + } else { + echo("$errMsg:", err = true) + validation.checks.filter { !it.passed }.forEach { + echo(" [✗] ${it.name}: ${it.message}", err = true) + } + } + throw ProgramResult(1) + } + + val manifest = + try { + PluginValidator.readManifestFromJar(targetJarFile) + ?: error("Failed to extract plugin manifest from JAR: ${targetJarFile.name}") + } catch (e: Exception) { + val msg = "Failed to extract plugin manifest from JAR: ${e.message}" + logger.error(LogCategory.SYSTEM, msg, error = e) + if (json) { + echo( + buildJsonObject { + put("status", "error") + put("error", msg) + }.toString(), + ) + } else { + echo("Error: $msg", err = true) + } + throw ProgramResult(1) + } + val pluginId = manifest.pluginId + + // Version-rotated staging to prevent Windows file locking collisions + val pluginDevBase = File(DevPluginArtifacts.stagingRoot(), pluginId) + val timestamp = System.currentTimeMillis() + val versionDir = File(pluginDevBase, "v$timestamp") + if (!versionDir.exists() && !versionDir.mkdirs()) { + val msg = "Failed to create version staging directory: ${versionDir.absolutePath}" + logger.error(LogCategory.SYSTEM, msg) + if (json) { + echo( + buildJsonObject { + put("status", "error") + put("error", msg) + }.toString(), + ) + } else { + echo("Error: $msg", err = true) + } + throw ProgramResult(1) + } + val partJar = File(versionDir, "$pluginId.jar.part") + val stagedJar = File(versionDir, "$pluginId.jar") + + try { + targetJarFile.copyTo(partJar, overwrite = true) + try { + Files.move( + partJar.toPath(), + stagedJar.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch ( + @Suppress("SwallowedException") e: AtomicMoveNotSupportedException, + ) { + logger.debug( + LogCategory.SYSTEM, + "ATOMIC_MOVE not supported, falling back to REPLACE_EXISTING", + mapOf("pluginId" to pluginId), + ) + Files.move( + partJar.toPath(), + stagedJar.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + } catch (e: Exception) { + val msg = "Failed to stage plugin in ${stagedJar.absolutePath}: ${e.message}" + logger.error(LogCategory.SYSTEM, msg, error = e) + if (json) { + echo( + buildJsonObject { + put("status", "error") + put("error", msg) + }.toString(), + ) + } else { + echo("Error: $msg", err = true) + } + throw ProgramResult(1) + } + + val reloadResult = SingleInstanceManager.reloadDevPlugin(pluginId) + when (reloadResult) { + is ReloadResult.Success -> { + if (json) { + val payload = + buildJsonObject { + put("status", "linked_and_reloaded") + put("pluginId", pluginId) + put("running", true) + put("stagedPath", stagedJar.absolutePath.replace('\\', '/')) + } + echo(payload.toString()) + } else { + echo( + "[✓] Plugin '$pluginId' staged at ${stagedJar.absolutePath} " + + "and reload signal confirmed by BossConsole.", + ) + } + } + + is ReloadResult.HostOffline -> { + if (json) { + val payload = + buildJsonObject { + put("status", "staged") + put("pluginId", pluginId) + put("running", false) + put("stagedPath", stagedJar.absolutePath.replace('\\', '/')) + put("notice", "BossConsole is offline; plugin staged for next launch") + } + echo(payload.toString()) + } else { + echo( + "[✓] Plugin '$pluginId' staged at ${stagedJar.absolutePath}. " + + "BossConsole is offline; plugin staged for next launch.", + ) + } + } + + is ReloadResult.TimedOut -> { + if (json) { + val payload = + buildJsonObject { + put("status", "staged_unconfirmed") + put("pluginId", pluginId) + put("running", true) + put("stagedPath", stagedJar.absolutePath.replace('\\', '/')) + put("notice", reloadResult.message) + } + echo(payload.toString()) + } else { + echo( + "[~] Plugin '$pluginId' staged at ${stagedJar.absolutePath}. " + + "${reloadResult.message}. It is picked up at the next launch.", + ) + } + } + + is ReloadResult.Failed -> { + val msg = "Plugin '$pluginId' staged, but reload failed: ${reloadResult.reason}" + logger.error(LogCategory.SYSTEM, msg) + if (json) { + val payload = + buildJsonObject { + put("status", "linked_reload_failed") + put("pluginId", pluginId) + put("running", true) + put("error", reloadResult.reason) + put("stagedPath", stagedJar.absolutePath.replace('\\', '/')) + } + echo(payload.toString()) + throw ProgramResult(1) + } else { + echo(msg, err = true) + throw ProgramResult(1) + } + } + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt index 4350a7784b..baea0ae9bc 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/main.kt @@ -24,6 +24,7 @@ import ai.rever.boss.logging.GlobalLogCapture import ai.rever.boss.performance.MemoryPressureWatchdog import ai.rever.boss.performance.PerformanceMonitor import ai.rever.boss.plugin.PluginStoreSetup +import ai.rever.boss.plugin.launchpad.DevPluginReloader import ai.rever.boss.plugin.sandbox.PluginExecutionBoundary import ai.rever.boss.plugin.sandbox.ui.PluginCrashInterceptor import ai.rever.boss.plugin.sandbox.ui.PluginCrashRegistry @@ -74,6 +75,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import java.awt.Window import javax.swing.JPopupMenu import kotlin.system.exitProcess @@ -359,7 +361,18 @@ fun main(args: Array) { ai.rever.boss.components.plugin.DefaultPlugin.Companion.loadPersistedPluginsInternal = { manager -> PluginStoreSetup.loadPersistedPlugins(manager) } + val defaultCheck = ai.rever.boss.components.plugin.DefaultPlugin.Companion.isAuthoritativeSystemPlugin + ai.rever.boss.components.plugin.DefaultPlugin.Companion.isAuthoritativeSystemPlugin = { pluginId -> + defaultCheck(pluginId) || PluginStoreSetup.isSystemPluginId(pluginId) + } + // Set up single-instance development reload handler + SingleInstanceManager.pluginReloadHandlerOverride = { pluginId -> + runBlocking { + DevPluginReloader.reload(pluginId).getOrThrow() + true + } + } GlobalLogCapture.start() ResourceModeConfig.publishToPlugins() diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginBuildProbe.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginBuildProbe.kt index ae012e44cc..df15584cd3 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginBuildProbe.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginBuildProbe.kt @@ -1,6 +1,7 @@ package ai.rever.boss.plugin import ai.rever.boss.components.plugin.PluginBuildInfo +import ai.rever.boss.plugin.launchpad.DevPluginArtifacts import ai.rever.boss.plugin.loader.PluginSignatureSidecar import ai.rever.boss.utils.logging.BossLogger import ai.rever.boss.utils.logging.LogCategory @@ -118,7 +119,11 @@ object PluginBuildProbe { reloadStamp = reloadStamp, ) - hooks.record(pluginId, jarPath, mtime ?: previous?.buildStamp, tagFor(info), plugin.version) + if (!DevPluginArtifacts + .isDevPluginJar(File(jarPath)) + ) { + hooks.record(pluginId, jarPath, mtime ?: previous?.buildStamp, tagFor(info), plugin.version) + } if (info.isTagged) { logger.info( diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginStoreSetup.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginStoreSetup.kt index 0accf9b9af..b5c53ab5a2 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginStoreSetup.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/PluginStoreSetup.kt @@ -1,7 +1,14 @@ package ai.rever.boss.plugin +import ai.rever.boss.components.plugin.DefaultPlugin +import ai.rever.boss.components.plugin.DynamicPluginInfo +import ai.rever.boss.components.plugin.DynamicPluginManager +import ai.rever.boss.components.plugin.HotReloadPolicy +import ai.rever.boss.components.plugin.PersistedPluginEntry import ai.rever.boss.config.GitHubConfig import ai.rever.boss.config.SupabaseClientConfig +import ai.rever.boss.plugin.api.PluginState +import ai.rever.boss.plugin.launchpad.DevPluginArtifacts import ai.rever.boss.plugin.loader.PluginBundledTrust import ai.rever.boss.plugin.loader.PluginManifestReader import ai.rever.boss.plugin.loader.PluginSignatureEnforcement @@ -1724,16 +1731,12 @@ object PluginStoreSetup { ), ) - val entries = - persistedPlugins.map { entry -> - ai.rever.boss.components.plugin.PersistedPluginEntry( - pluginId = entry.pluginId, - jarPath = entry.jarPath, - enabled = entry.enabled, - ) - } - - val persistedResults = dynamicPluginManager.loadPersistedPlugins(entries) + val persistedResults = + loadPersistedPluginEntries( + dynamicPluginManager = dynamicPluginManager, + persistedPlugins = persistedPlugins, + devRoot = DevPluginArtifacts.stagingRoot(), + ) results.putAll(persistedResults) // Repoint installed.json at what actually loaded: a stale persisted @@ -1746,7 +1749,10 @@ object PluginStoreSetup { for ((pluginId, result) in persistedResults) { val loaded = result.getOrNull() ?: continue val persisted = persistedById[pluginId] ?: continue - if (persisted.jarPath != loaded.jarPath) { + if (persisted.jarPath != loaded.jarPath && + !DevPluginArtifacts + .isDevPluginJar(File(loaded.jarPath)) + ) { PluginPersistence.addInstalledPlugin( pluginId = pluginId, jarPath = loaded.jarPath, @@ -1777,6 +1783,136 @@ object PluginStoreSetup { return results } + internal suspend fun loadPersistedPluginEntries( + dynamicPluginManager: DynamicPluginManager, + persistedPlugins: List, + devRoot: File = DevPluginArtifacts.stagingRoot(), + ): Map> { + val entries = + persistedPlugins.map { entry -> + PersistedPluginEntry( + pluginId = entry.pluginId, + jarPath = resolvePersistedEntryPath(entry, devRoot), + enabled = entry.enabled, + ) + } + + val initialResults = dynamicPluginManager.loadPersistedPlugins(entries) + val finalResults = initialResults.toMutableMap() + val persistedById = persistedPlugins.associateBy { it.pluginId } + + for ((pluginId, result) in initialResults) { + val entry = persistedById[pluginId] ?: continue + val attemptedPath = entries.firstOrNull { it.pluginId == pluginId }?.jarPath + val isDevSwap = attemptedPath != null && attemptedPath != entry.jarPath + val loadedInfo = result.getOrNull() + val isFailedDevSwap = + result.isFailure || ( + isDevSwap && entry.enabled && + loadedInfo?.state == PluginState.DISABLED && + dynamicPluginManager.canAccess(loadedInfo.manifest) + ) + + if (isFailedDevSwap) { + val fallbackResult = + attemptStoreFallbackOnDevFailure( + dynamicPluginManager = dynamicPluginManager, + pluginId = pluginId, + entry = entry, + attemptedPath = attemptedPath, + devError = + result.exceptionOrNull() + ?: IllegalStateException( + result.getOrNull()?.errorMessage ?: "Dev plugin loaded in DISABLED state", + ), + ) + if (fallbackResult != null) { + finalResults[pluginId] = fallbackResult + } + } + } + return finalResults + } + + private suspend fun attemptStoreFallbackOnDevFailure( + dynamicPluginManager: DynamicPluginManager, + pluginId: String, + entry: PluginPersistence.InstalledPluginEntry, + attemptedPath: String?, + devError: Throwable?, + ): Result? { + val isDevBuildSwap = + attemptedPath != null && + attemptedPath != entry.jarPath && + DevPluginArtifacts.isDevPluginJar(File(attemptedPath)) + val storeJar = File(entry.jarPath) + if (!isDevBuildSwap || !storeJar.exists()) { + return null + } + + logger.warn( + LogCategory.SYSTEM, + "Dev plugin failed to load at startup; falling back to store build", + mapOf( + "pluginId" to pluginId, + "devJarPath" to attemptedPath, + "storeJarPath" to entry.jarPath, + "error" to (devError?.message ?: "unknown"), + ), + devError, + ) + val fallbackResult = + dynamicPluginManager.installPlugin(entry.jarPath, enabled = entry.enabled) + if (fallbackResult.isSuccess) { + logger.info( + LogCategory.SYSTEM, + "Successfully fell back to store build for plugin $pluginId", + mapOf("pluginId" to pluginId, "storeJarPath" to entry.jarPath), + ) + } else { + logger.error( + LogCategory.SYSTEM, + "Fallback to store build also failed for plugin $pluginId", + mapOf("pluginId" to pluginId, "storeJarPath" to entry.jarPath), + fallbackResult.exceptionOrNull(), + ) + } + return fallbackResult + } + + internal fun resolvePersistedEntryPath( + entry: PluginPersistence.InstalledPluginEntry, + devRoot: File = DevPluginArtifacts.stagingRoot(), + ): String { + val devJar = + DevPluginArtifacts.findActiveDevJar( + entry.pluginId, + devRoot, + ) + if (devJar != null && devJar.exists() && !isProtectedFromDevSwap(entry.pluginId)) { + if (DevPluginArtifacts.isValidDevJar(devJar, entry.pluginId)) { + return devJar.absolutePath + } + logger.warn( + LogCategory.SYSTEM, + "Skipping corrupted or incomplete dev build at ${devJar.absolutePath}; falling back to store build", + mapOf( + "pluginId" to entry.pluginId, + "devJarPath" to devJar.absolutePath, + "storeJarPath" to entry.jarPath, + ), + ) + } + return entry.jarPath + } + + internal fun isSystemPluginId(pluginId: String): Boolean = systemPlugins.any { it.pluginId == pluginId } + + private fun isProtectedFromDevSwap(pluginId: String): Boolean = + DefaultPlugin.isAuthoritativeSystemPlugin(pluginId) || + isSystemPluginId(pluginId) || + HotReloadPolicy.requiresRestartInsteadOfHotReload(pluginId) + /** * Copy bundled plugins from app resources to ~/.boss/plugins directory. * Only copies if the plugin is not already installed or if the bundled version is newer. diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloader.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloader.kt new file mode 100644 index 0000000000..b3351005d8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloader.kt @@ -0,0 +1,323 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.components.plugin.DefaultPlugin +import ai.rever.boss.components.plugin.DynamicPluginInfo +import ai.rever.boss.components.plugin.DynamicPluginManager +import ai.rever.boss.components.plugin.HotReloadPolicy +import ai.rever.boss.plugin.api.CanUnloadResult +import ai.rever.boss.plugin.api.PluginState +import ai.rever.boss.plugin.loader.PluginUnloadException +import ai.rever.boss.utils.logging.BossLogger +import ai.rever.boss.utils.logging.LogCategory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Executes dev plugin hot-reload by dispatching unload and install requests directly to DynamicPluginManager. + */ +@Suppress("ReturnCount", "ThrowsCount", "TooGenericExceptionCaught") +object DevPluginReloader { + private val logger = BossLogger.forComponent("DevPluginReloader") + private val reloadLocks = ConcurrentHashMap() + private val sessionPreservedPaths = ConcurrentHashMap>() + + internal fun clearSessionPreservedPathsForTest() { + sessionPreservedPaths.clear() + } + + /** + * Reloads the active dev build of [pluginId] into all running host [DynamicPluginManager] instances. + * Preserves native/browser restart policies, runs a dry-run pre-flight check across all managers + * before unloading, unloads running instances without force, loads fresh bytes, and prunes old + * staging directories on the host while strictly retaining any JAR path actively referenced by any manager. + * Executes strictly on [Dispatchers.Main] to prevent UI hierarchy threading deadlocks. + * + * The unload deliberately does not wait for classloader garbage collection: the poll loop + * runs [System.gc] on the caller's thread, and running it here would freeze the UI for up + * to the GC watcher's timeout. Every host reload path takes the same default; the old + * loader is reclaimed out of band once nothing references it. + */ + suspend fun reload( + pluginId: String, + devRoot: File = DevPluginArtifacts.stagingRoot(), + ): Result { + val mutex = reloadLocks.computeIfAbsent(pluginId) { Mutex() } + return mutex.withLock { + withContext(Dispatchers.Main) { + runCatching { + executeReload(pluginId, devRoot) + } + } + } + } + + private suspend fun executeReload( + pluginId: String, + devRoot: File, + ) { + val activeManagers = DynamicPluginManager.activeManagers() + if (activeManagers.isEmpty()) { + error("Host plugin manager is not yet initialized") + } + + if (HotReloadPolicy.requiresRestartInsteadOfHotReload(pluginId)) { + val message = "Plugin $pluginId owns native resources that require a full application restart" + logger.warn(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId)) + error(message) + } + + val stagedJar = + DevPluginArtifacts.findActiveDevJar(pluginId, devRoot) + ?: error("No staged dev JAR found for plugin $pluginId in ${devRoot.absolutePath}") + + recordSessionPreservedPaths(pluginId, listOf(stagedJar.absolutePath)) + + logger.info( + LogCategory.SYSTEM, + "Initiating dev plugin reload", + mapOf( + "pluginId" to pluginId, + "jarPath" to stagedJar.absolutePath, + "managersCount" to activeManagers.size, + ), + ) + + val priorStates = + activeManagers.map { manager -> + val info = manager.getPluginInfo(pluginId) + PriorManagerState( + manager = manager, + priorJarPath = info?.jarPath, + wasLoaded = info?.state == PluginState.LOADED, + wasEnabled = info?.enabled ?: true, + ) + } + val priorPaths = priorStates.mapNotNull { it.priorJarPath } + recordSessionPreservedPaths(pluginId, priorPaths) + + preflightCheck(pluginId, activeManagers) + val modifiedManagers = mutableSetOf() + try { + unloadManagers(pluginId, activeManagers, modifiedManagers) + installManagers(pluginId, stagedJar, activeManagers, priorStates, modifiedManagers) + } catch (e: Exception) { + val rollbackErrors = rollbackManagers(pluginId, priorStates, modifiedManagers) + if (rollbackErrors.isNotEmpty()) { + rollbackErrors.values.forEach { rollbackError -> + e.addSuppressed(rollbackError) + } + } + throw e + } + + pruneStaging(pluginId, devRoot, sessionPreservedPaths[pluginId].orEmpty()) + + logger.info( + LogCategory.SYSTEM, + "Dev plugin reloaded successfully", + mapOf("pluginId" to pluginId, "jarPath" to stagedJar.absolutePath), + ) + } + + internal data class PriorManagerState( + val manager: DynamicPluginManager, + val priorJarPath: String?, + val wasLoaded: Boolean, + val wasEnabled: Boolean, + ) + + internal suspend fun rollbackManagers( + pluginId: String, + priorStates: List, + modifiedManagers: Set = priorStates.map { it.manager }.toSet(), + ): Map { + val targets = priorStates.filter { it.manager in modifiedManagers } + logger.warn( + LogCategory.SYSTEM, + "Rolling back dev plugin reload across modified managers", + mapOf("pluginId" to pluginId, "managersCount" to targets.size), + ) + val rollbackErrors = mutableMapOf() + for (priorState in targets) { + val outcome = rollbackSingleManager(pluginId, priorState) + outcome.exceptionOrNull()?.let { error -> + rollbackErrors[priorState.manager] = error + } + } + return rollbackErrors + } + + private suspend fun rollbackSingleManager( + pluginId: String, + priorState: PriorManagerState, + ): Result = + runCatching { + val hasValidPriorJar = + priorState.priorJarPath != null && + File(priorState.priorJarPath).exists() + if (hasValidPriorJar) { + // Hot-reload case: reinstall the prior working JAR with previous enabled state. + // Always force-uninstall any existing or partially loaded instance first. + val uninstallResult = + priorState.manager.uninstallPlugin(pluginId, force = true, waitForGC = false) + if (uninstallResult.isFailure) { + val error = uninstallResult.exceptionOrNull() + val isNotFound = + (error is PluginUnloadException && error.message?.contains("Plugin not found") == true) || + error?.message?.contains("Plugin not found") == true + if (!isNotFound) { + val message = "Failed to force-uninstall plugin $pluginId during rollback: ${error?.message}" + logger.error(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId), error) + throw IllegalStateException(message, error) + } + } + val installResult = + priorState.manager.installPlugin(priorState.priorJarPath, enabled = priorState.wasEnabled) + val restored = installResult.getOrNull() + if (!isLoadedOrHidden(restored, priorState.wasEnabled, priorState.manager)) { + val error = installResult.exceptionOrNull() + val message = + "Failed to reinstall prior JAR ${priorState.priorJarPath} during rollback: " + + (error?.message ?: "restored plugin is not running (state: ${restored?.state})") + logger.error(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId), error) + throw IllegalStateException(message, error) + } + } else { + // First link case: was newly installed during this reload cycle; + // uninstall it to restore clean initial state. + val uninstallResult = + priorState.manager.uninstallPlugin(pluginId, force = true, waitForGC = false) + if (uninstallResult.isFailure) { + val error = uninstallResult.exceptionOrNull() + val isNotFound = + (error is PluginUnloadException && error.message?.contains("Plugin not found") == true) || + error?.message?.contains("Plugin not found") == true + if (!isNotFound) { + val message = + "Failed to clean up newly installed plugin $pluginId during rollback: ${error?.message}" + logger.error(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId), error) + throw IllegalStateException(message, error) + } + } + } + }.onFailure { e -> + logger.error( + LogCategory.SYSTEM, + "Failed to rollback plugin $pluginId on manager", + mapOf("pluginId" to pluginId), + e, + ) + } + + private suspend fun preflightCheck( + pluginId: String, + managers: List, + ) { + if (DefaultPlugin.isAuthoritativeSystemPlugin(pluginId)) { + val message = "Plugin '$pluginId' is a protected system plugin and cannot be hot-reloaded" + logger.warn(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId)) + error(message) + } + + for (manager in managers) { + val info = manager.getPluginInfo(pluginId) ?: continue + if (manager.isSystemPlugin(pluginId) || info.manifest.canUnload == false) { + val message = "Plugin '$pluginId' is a protected system plugin" + logger.warn(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId)) + error(message) + } + val canUnloadResult = manager.checkCanUnload(pluginId) + if (canUnloadResult is CanUnloadResult.NotAllowed) { + val reasons = canUnloadResult.reasons.joinToString(", ") + val message = "Cannot unload '$pluginId' due to active dependents: $reasons" + logger.warn(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId, "reasons" to reasons)) + error(message) + } + } + } + + private suspend fun unloadManagers( + pluginId: String, + managers: List, + modifiedManagers: MutableSet, + ) { + for (manager in managers) { + if (manager.getPluginInfo(pluginId) != null) { + val unloadResult = manager.uninstallPlugin(pluginId, force = false, waitForGC = false) + if (unloadResult.isFailure) { + val error = unloadResult.exceptionOrNull() + val message = "Failed to unload plugin $pluginId: ${error?.message ?: "unknown"}" + logger.error(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId), error) + throw IllegalStateException(message, error) + } + modifiedManagers.add(manager) + } + } + } + + private suspend fun installManagers( + pluginId: String, + stagedJar: File, + managers: List, + priorStates: List, + modifiedManagers: MutableSet, + ) { + val priorByManager = priorStates.associateBy { it.manager } + for (manager in managers) { + modifiedManagers.add(manager) + val targetEnabled = priorByManager[manager]?.wasEnabled ?: true + val installResult = manager.installPlugin(stagedJar.absolutePath, enabled = targetEnabled) + val installed = installResult.getOrNull() + if (!isLoadedOrHidden(installed, targetEnabled, manager)) { + val error = installResult.exceptionOrNull() + val state = installed?.state?.name ?: "unknown" + val message = + "Staged dev JAR for $pluginId is not running (state: $state): " + + "${error?.message ?: "installation did not report a loaded plugin"}" + logger.error(LogCategory.SYSTEM, message, mapOf("pluginId" to pluginId), error) + throw IllegalStateException(message, error) + } + } + } + + private fun recordSessionPreservedPaths( + pluginId: String, + paths: Collection, + ) { + if (paths.isEmpty()) return + sessionPreservedPaths + .computeIfAbsent(pluginId) { ConcurrentHashMap.newKeySet() } + .addAll(paths) + } + + private fun pruneStaging( + pluginId: String, + devRoot: File, + preservedPaths: Set = emptySet(), + ) { + val activeJarPaths = + DynamicPluginManager + .activeManagers() + .mapNotNull { it.getPluginInfo(pluginId)?.jarPath } + .toSet() + preservedPaths + val pluginDevDir = DevPluginArtifacts.pluginDevDir(pluginId, devRoot) + DevPluginArtifacts.pruneStagingHistory( + pluginDevDir = pluginDevDir, + activeJarPaths = activeJarPaths, + ) + } +} + +private fun isLoadedOrHidden( + installed: DynamicPluginInfo?, + enabled: Boolean, + manager: DynamicPluginManager, +): Boolean = + installed != null && ( + installed.state == PluginState.LOADED || + (installed.state == PluginState.DISABLED && (!enabled || !manager.canAccess(installed.manifest))) + ) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginManifest.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginManifest.kt new file mode 100644 index 0000000000..b9c080ac43 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginManifest.kt @@ -0,0 +1,209 @@ +package ai.rever.boss.plugin.launchpad + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Canonical permission vocabulary supported by BossConsole host runtime. + */ +enum class PluginPermission( + val identifier: String, +) { + NETWORK("network"), + FILESYSTEM("filesystem"), + TERMINAL("terminal"), + BROWSER("browser"), + NOTIFICATIONS("notifications"), + AUTH("auth"), + MCP("mcp"), + EDITOR("editor"), + CLIPBOARD("clipboard"), + SETTINGS("settings"), + SYSTEM("system"), + STORAGE("storage"), + ; + + companion object { + private val lookup = entries.associateBy { it.identifier } + + fun isValid(name: String): Boolean = lookup.containsKey(name) + + fun fromIdentifier(name: String): PluginPermission? = lookup[name] + } +} + +/** + * Manifest schema for BossConsole plugins (matching META-INF/boss-plugin/plugin.json). + * Supports canonical fields and aliases for backward-compatibility. + */ +@Serializable +data class PluginManifest( + @SerialName("pluginId") + val pluginId: String = "", + @SerialName("displayName") + val displayName: String = "", + val version: String = "1.0.0", + @SerialName("apiVersion") + val apiVersion: String = HostMeta.CURRENT_API_VERSION, + @SerialName("mainClass") + val mainClass: String = "", + val description: String = "", + val author: String = "", + val license: String = "Apache-2.0", + val manifestVersion: Int = 1, + @SerialName("systemPlugin") + val systemPlugin: Boolean = false, + @SerialName("canUnload") + val canUnload: Boolean = true, + @SerialName("requiredPermissions") + val requiredPermissions: List = emptyList(), + val mcpTools: List = emptyList(), + // Backward-compatibility aliases for legacy JSON fields + @SerialName("id") + private val legacyId: String? = null, + @SerialName("name") + private val legacyName: String? = null, + @SerialName("minApiVersion") + private val legacyMinApiVersion: String? = null, + @SerialName("entrypointClass") + private val legacyEntrypointClass: String? = null, + @SerialName("permissions") + private val legacyPermissions: List? = null, +) { + val id: String get() = pluginId.ifBlank { legacyId.orEmpty() } + val name: String get() = displayName.ifBlank { legacyName.orEmpty() } + val minApiVersion: String get() = apiVersion.ifBlank { legacyMinApiVersion.orEmpty() } + val entrypointClass: String get() = mainClass.ifBlank { legacyEntrypointClass.orEmpty() } + val permissions: List get() = requiredPermissions.ifEmpty { legacyPermissions.orEmpty() } + + /** Returns the canonical plugin ID. */ + fun resolvedPluginId(): String = pluginId.ifBlank { legacyId.orEmpty() } + + /** Returns the canonical display name. */ + fun resolvedDisplayName(): String = displayName.ifBlank { legacyName.orEmpty() } + + /** Returns the canonical entrypoint class. */ + fun resolvedMainClass(): String = mainClass.ifBlank { legacyEntrypointClass.orEmpty() } + + /** Returns the canonical API version. */ + fun resolvedApiVersion(): String = apiVersion.ifBlank { legacyMinApiVersion.orEmpty() } +} + +@Serializable +data class PluginMcpToolDeclaration( + val name: String, + val description: String, + val adminOnly: Boolean = false, +) + +@Serializable +data class ValidationCheck( + val name: String, + val passed: Boolean, + val message: String, +) + +@Serializable +data class ValidationResult( + val isValid: Boolean, + val checks: List, +) + +@Serializable +data class ValidationFailure( + val checkName: String, + val message: String, +) + +@Serializable +data class ValidationReport( + val success: Boolean, + val targetPath: String, + val checksPassed: Int, + val totalChecks: Int, + val failures: List = emptyList(), + val checks: List = emptyList(), +) + +object HostMeta { + val CURRENT_API_VERSION: String = resolveCurrentApiVersion() + + // VersionConstants is generated from libs.versions.toml, so the fallback + // here is a compile-time constant, not a second hand-copied version. + private fun resolveCurrentApiVersion(): String = + System.getProperty("boss.plugin.api.version") + ?: ai.rever.boss.utils.VersionConstants.PLUGIN_API_VERSION + + /** + * Standard RBAC permission format: dot/dash/underscore-separated alphanumeric tokens + * (e.g. 'plugins.create', 'secret.read'). + */ + val PERMISSION_REGEX: Regex = Regex("""^[a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*$""") + + fun isValidPermissionIdentifier(permission: String): Boolean { + val trimmed = permission.trim() + return trimmed.isNotEmpty() && PERMISSION_REGEX.matches(trimmed) + } + + @Deprecated("Permissions in BOSS correspond to dynamic host RBAC permissions rather than a fixed enum.") + val ALLOWED_PERMISSIONS: Set = + PluginPermission.entries.map { it.identifier }.toSet() +} + +/** + * SemVer 2.0 conformance and compatibility validator. + */ +object SemVerValidator { + val SEMVER_REGEX = + Regex( + """^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)""" + + """(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?""" + + """(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$""", + ) + + fun isValid(version: String): Boolean = SEMVER_REGEX.matches(version) + + fun isCompatible( + requiredMin: String, + hostVersion: String, + ): Boolean { + if (!isValid(requiredMin) || !isValid(hostVersion)) return false + val reqParts = + requiredMin + .split('-')[0] + .split('+')[0] + .split('.') + .map { it.toIntOrNull() ?: 0 } + val hostParts = + hostVersion + .split('-')[0] + .split('+')[0] + .split('.') + .map { it.toIntOrNull() ?: 0 } + + val rMajor = reqParts.getOrElse(0) { 0 } + val rMinor = reqParts.getOrElse(1) { 0 } + val rPatch = reqParts.getOrElse(2) { 0 } + + val hMajor = hostParts.getOrElse(0) { 0 } + val hMinor = hostParts.getOrElse(1) { 0 } + val hPatch = hostParts.getOrElse(2) { 0 } + + return when { + hMajor != rMajor -> false + hMinor > rMinor -> true + hMinor == rMinor -> hPatch >= rPatch + else -> false + } + } +} + +val launchpadJson = + Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + encodeDefaults = true + explicitNulls = false + } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginScaffolder.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginScaffolder.kt new file mode 100644 index 0000000000..14c2a7cbeb --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginScaffolder.kt @@ -0,0 +1,734 @@ +package ai.rever.boss.plugin.launchpad + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +/** + * Scaffolds third-party plugin projects with standard structure, Gradle wrapper, build, and tests. + */ +@Suppress("LongMethod", "TooManyFunctions", "ComplexCondition", "LargeClass") +object PluginScaffolder { + enum class Template( + val type: String, + ) { + MCP_TOOL("mcp-tool"), + UI_PANEL("ui-panel"), + BACKGROUND_SERVICE("background-service"), + FULL("full"), + ; + + companion object { + fun fromString(value: String): Template { + val normalized = value.lowercase().trim() + return entries.firstOrNull { it.type == normalized } + ?: throw IllegalArgumentException( + "Unknown template '$value'. Supported: mcp-tool, ui-panel, background-service, full", + ) + } + } + } + + data class ScaffoldResult( + val pluginId: String, + val targetDirectory: File, + val filesCreated: List, + ) + + @Suppress("CyclomaticComplexMethod", "LongMethod") + fun scaffold( + name: String, + templateName: String = "mcp-tool", + targetDir: File, + force: Boolean = false, + ): ScaffoldResult { + val template = Template.fromString(templateName) + + if (targetDir.exists() && targetDir.isDirectory) { + val existingFiles = targetDir.listFiles() + if (existingFiles?.isNotEmpty() == true) { + if (!force) { + val path = targetDir.absolutePath + error("Target directory '$path' exists and is not empty. Use --force to overwrite.") + } + assertSafeToPurge(targetDir) + existingFiles.forEach { it.deleteRecursively() } + } + } + + if (!targetDir.exists() && !targetDir.mkdirs()) { + throw IOException("Failed to create directory: ${targetDir.absolutePath}") + } + + val baseSuffix = + if (name.contains('.')) { + name.substringAfterLast('.').ifBlank { "custom-plugin" } + } else { + name + } + val rawSuffix = + baseSuffix + .trim() + .lowercase() + .replace(Regex("[^a-z0-9-]"), "-") + .replace(Regex("-+"), "-") + .trim('-') + .ifBlank { "custom-plugin" } + + val pluginId = derivePluginId(name) + + val className = sanitizeClassIdentifier(name) + val packageSuffix = sanitizePackageIdentifier(rawSuffix) + val packageName = "com.example.$packageSuffix" + val packageDirRel = "com/example/$packageSuffix" + + // Starter templates are unconstrained by default so any authenticated non-admin developer + // can load and test them without hitting RBAC permission gating (pluginAccessAllowed). + val requiredPermissions = emptyList() + + val mcpTools = + when (template) { + Template.MCP_TOOL, Template.FULL -> { + listOf( + PluginMcpToolDeclaration( + name = "mcp__${pluginId.replace('.', '_').replace('-', '_')}__action", + description = "Executes $name action tool", + adminOnly = false, + ), + ) + } + + else -> { + emptyList() + } + } + + val manifest = + PluginManifest( + pluginId = pluginId, + displayName = name, + version = "0.1.0", + description = "BossConsole plugin for $name ($templateName template)", + author = "Boss Developer", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "$packageName.$className", + manifestVersion = 1, + systemPlugin = false, + canUnload = true, + requiredPermissions = requiredPermissions, + mcpTools = mcpTools, + ) + + val filesCreated = mutableListOf() + + // 1. Root plugin.json + val manifestFile = File(targetDir, "plugin.json") + val manifestContent = launchpadJson.encodeToString(manifest) + manifestFile.writeText(manifestContent) + filesCreated += manifestFile + + // 1b. Resource manifest: src/main/resources/META-INF/boss-plugin/plugin.json + val metaInfDir = File(targetDir, "src/main/resources/META-INF/boss-plugin") + metaInfDir.mkdirs() + val resourceManifest = File(metaInfDir, "plugin.json") + resourceManifest.writeText(manifestContent) + filesCreated += resourceManifest + + // 2. build.gradle.kts + val gradleFile = File(targetDir, "build.gradle.kts") + gradleFile.writeText(generateBuildGradle(HostMeta.CURRENT_API_VERSION)) + filesCreated += gradleFile + + // 2b. libs directory for local development fallback + val libsDir = File(targetDir, "libs") + libsDir.mkdirs() + + // 3. settings.gradle.kts + val settingsFile = File(targetDir, "settings.gradle.kts") + settingsFile.writeText("rootProject.name = \"$pluginId\"\n") + filesCreated += settingsFile + + // 4. Gradle wrapper files: gradle/wrapper/gradle-wrapper.properties and gradle-wrapper.jar + val wrapperDir = File(targetDir, "gradle/wrapper") + wrapperDir.mkdirs() + val wrapperPropsFile = File(wrapperDir, "gradle-wrapper.properties") + wrapperPropsFile.writeText(generateGradleWrapperProperties()) + filesCreated += wrapperPropsFile + + val wrapperJarFile = File(wrapperDir, "gradle-wrapper.jar") + copyOrGenerateWrapperJar(wrapperJarFile) + filesCreated += wrapperJarFile + + // 5. gradlew (POSIX executable script) + val gradlewFile = File(targetDir, "gradlew") + val gradlewContent = generateGradlew().replace("\r\n", "\n").replace("\r", "\n") + gradlewFile.writeBytes(gradlewContent.toByteArray(Charsets.UTF_8)) + setExecutablePermissions(gradlewFile) + filesCreated += gradlewFile + + // 6. gradlew.bat (Windows batch script) + val gradlewBatFile = File(targetDir, "gradlew.bat") + gradlewBatFile.writeText(generateGradlewBat()) + filesCreated += gradlewBatFile + + // 7. Entrypoint source file: src/main/kotlin//.kt + val srcDir = File(targetDir, "src/main/kotlin/$packageDirRel") + srcDir.mkdirs() + val entrypointFile = File(srcDir, "$className.kt") + entrypointFile.writeText(generateSourceFile(packageName, className, name, pluginId, template)) + filesCreated += entrypointFile + + // 8. Sample test file: src/test/kotlin//Test.kt + val testDir = File(targetDir, "src/test/kotlin/$packageDirRel") + testDir.mkdirs() + val testFile = File(testDir, "${className}Test.kt") + testFile.writeText(generateTestFile(packageName, className, name, pluginId)) + filesCreated += testFile + + // 9. .gitignore + val gitignoreFile = File(targetDir, ".gitignore") + gitignoreFile.writeText(generateGitignore()) + filesCreated += gitignoreFile + + // 10. README.md + val readmeFile = File(targetDir, "README.md") + readmeFile.writeText(generateReadme(name, pluginId, template.type, HostMeta.CURRENT_API_VERSION)) + filesCreated += readmeFile + + return ScaffoldResult(pluginId, targetDir, filesCreated) + } + + private fun setExecutablePermissions(file: File) { + try { + file.setExecutable(true, false) + } catch (_: Exception) { + } + try { + val perms = + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_EXECUTE, + ) + Files.setPosixFilePermissions(file.toPath(), perms) + } catch (_: Exception) { + } + } + + private fun copyOrGenerateWrapperJar(targetJar: File) { + val resourceStream = + PluginScaffolder::class.java.getResourceAsStream("/launcher/gradle-wrapper.jar") + ?: error("Resource /launcher/gradle-wrapper.jar not found in host resources") + resourceStream.use { input -> + targetJar.outputStream().use { output -> + input.copyTo(output) + } + } + } + + private fun generateGradleWrapperProperties(): String = + """ + distributionBase=GRADLE_USER_HOME + distributionPath=wrapper/dists + distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip + zipStoreBase=GRADLE_USER_HOME + zipStorePath=wrapper/dists + """.trimIndent() + "\n" + + private fun generateGradlew(): String = + """ + #!/bin/sh + # Minimal gradlew launcher + set -e + PRG="${'$'}0" + while [ -h "${'$'}PRG" ]; do + link="${'$'}(readlink "${'$'}PRG" 2>/dev/null || true)" + if [ -z "${'$'}link" ]; then + break + fi + case "${'$'}link" in + /*) PRG="${'$'}link" ;; + *) PRG="${'$'}(dirname "${'$'}PRG")/${'$'}link" ;; + esac + done + APP_HOME="${'$'}(cd "${'$'}(dirname "${'$'}PRG")" >/dev/null 2>&1 && pwd)" + exec java -jar "${'$'}APP_HOME/gradle/wrapper/gradle-wrapper.jar" "${'$'}@" + """.trimIndent() + "\n" + + private fun generateGradlewBat(): String = + """ + @rem Minimal gradlew.bat launcher + @if "%DEBUG%"=="" @echo off + set DIRNAME=%~dp0 + if "%DIRNAME%"=="" set DIRNAME=. + java -jar "%DIRNAME%gradle\wrapper\gradle-wrapper.jar" %* + """.trimIndent() + "\n" + + private fun generateBuildGradle(apiVersion: String): String = + """ + plugins { + kotlin("jvm") version "2.0.21" + kotlin("plugin.serialization") version "2.0.21" + `java-library` + } + + kotlin { + compilerOptions { + freeCompilerArgs.add("-Xskip-metadata-version-check") + } + } + + repositories { + mavenCentral() + mavenLocal() + ivy { + url = uri("https://github.com/risa-labs-inc/boss-plugin-api/releases/download") + patternLayout { + artifact("v[revision]/[artifact]-[revision].[ext]") + artifact("[revision]/[artifact]-[revision].[ext]") + } + metadataSources { artifact() } + } + flatDir { + dirs("libs") + } + } + + dependencies { + compileOnly("ai.rever.boss:boss-plugin-api:$apiVersion") + testImplementation("ai.rever.boss:boss-plugin-api:$apiVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + testImplementation(kotlin("test")) + testImplementation(kotlin("test-junit5")) + } + + tasks.test { + useJUnitPlatform() + } + """.trimIndent() + "\n" + + private fun escapeString(raw: String): String = + raw + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\\$") + .replace("\r", "") + .replace("\n", " ") + + private fun generateSourceFile( + packageName: String, + className: String, + pluginName: String, + pluginId: String, + template: Template, + ): String { + val escapedName = escapeString(pluginName) + val sanitizedToolName = pluginId.replace('-', '_').replace('.', '_') + + return when (template) { + Template.MCP_TOOL -> { + """ + package $packageName + + import ai.rever.boss.plugin.api.McpToolDefinition + import ai.rever.boss.plugin.api.McpToolHandler + import ai.rever.boss.plugin.api.McpToolProvider + import ai.rever.boss.plugin.api.McpToolResult + import ai.rever.boss.plugin.api.Plugin + import ai.rever.boss.plugin.api.PluginContext + + class $className : Plugin { + override val pluginId: String = "$pluginId" + override val displayName: String = "$escapedName" + + private var toolProvider: McpToolProvider? = null + + override fun register(context: PluginContext) { + val provider = + object : McpToolProvider { + override val providerId: String = pluginId + + override fun tools(): List = + listOf( + McpToolDefinition( + name = "mcp__${sanitizedToolName}__action", + description = "Executes $escapedName action tool", + handler = + McpToolHandler { _ -> + McpToolResult("Action executed successfully for $escapedName") + }, + ), + ) + } + toolProvider = provider + context.registerMcpToolProvider(provider) + } + + override fun dispose() { + toolProvider = null + println("Disposed $escapedName ($pluginId)") + } + } + """.trimIndent() + "\n" + } + + Template.UI_PANEL -> { + """ + package $packageName + + import ai.rever.boss.plugin.api.PanelId + import ai.rever.boss.plugin.api.PanelMenuContribution + import ai.rever.boss.plugin.api.PanelMenuItem + import ai.rever.boss.plugin.api.Plugin + import ai.rever.boss.plugin.api.PluginContext + + class $className : Plugin { + override val pluginId: String = "$pluginId" + override val displayName: String = "$escapedName" + + private var menuContribution: PanelMenuContribution? = null + + override fun register(context: PluginContext) { + val contribution = + object : PanelMenuContribution { + override val contributionId: String = "$pluginId.menu" + override val targetPanels: Set = emptySet() + + override fun items(panelId: PanelId): List = + listOf( + PanelMenuItem( + id = "$pluginId.action", + label = "$escapedName Action", + ), + ) + + override fun onItemClick( + panelId: PanelId, + itemId: String, + windowId: String?, + ) { + println("Panel menu item clicked: ${'$'}itemId for $escapedName") + } + } + menuContribution = contribution + context.registerPanelMenuContribution(contribution) + } + + override fun dispose() { + menuContribution = null + println("Disposed $escapedName ($pluginId)") + } + } + """.trimIndent() + "\n" + } + + Template.BACKGROUND_SERVICE -> { + """ + package $packageName + + import ai.rever.boss.plugin.api.Plugin + import ai.rever.boss.plugin.api.PluginContext + import kotlinx.coroutines.Job + import kotlinx.coroutines.delay + import kotlinx.coroutines.isActive + import kotlinx.coroutines.launch + + class $className : Plugin { + override val pluginId: String = "$pluginId" + override val displayName: String = "$escapedName" + + private var workerJob: Job? = null + + override fun register(context: PluginContext) { + workerJob = + context.pluginScope.launch { + println("Background service started for $escapedName ($pluginId)") + while (isActive) { + delay(30_000) + println("Background heartbeat for $escapedName") + } + } + } + + override fun dispose() { + workerJob?.cancel() + workerJob = null + println("Background service stopped for $escapedName ($pluginId)") + } + } + """.trimIndent() + "\n" + } + + Template.FULL -> { + """ + package $packageName + + import ai.rever.boss.plugin.api.McpToolDefinition + import ai.rever.boss.plugin.api.McpToolHandler + import ai.rever.boss.plugin.api.McpToolProvider + import ai.rever.boss.plugin.api.McpToolResult + import ai.rever.boss.plugin.api.PanelId + import ai.rever.boss.plugin.api.PanelMenuContribution + import ai.rever.boss.plugin.api.PanelMenuItem + import ai.rever.boss.plugin.api.Plugin + import ai.rever.boss.plugin.api.PluginContext + import kotlinx.coroutines.Job + import kotlinx.coroutines.delay + import kotlinx.coroutines.isActive + import kotlinx.coroutines.launch + + class $className : Plugin { + override val pluginId: String = "$pluginId" + override val displayName: String = "$escapedName" + + private var toolProvider: McpToolProvider? = null + private var menuContribution: PanelMenuContribution? = null + private var workerJob: Job? = null + + override fun register(context: PluginContext) { + val provider = + object : McpToolProvider { + override val providerId: String = pluginId + + override fun tools(): List = + listOf( + McpToolDefinition( + name = "mcp__${sanitizedToolName}__action", + description = "Executes $escapedName action tool", + handler = + McpToolHandler { _ -> + McpToolResult("Action executed successfully for $escapedName") + }, + ), + ) + } + toolProvider = provider + context.registerMcpToolProvider(provider) + + val contribution = + object : PanelMenuContribution { + override val contributionId: String = "$pluginId.menu" + override val targetPanels: Set = emptySet() + + override fun items(panelId: PanelId): List = + listOf( + PanelMenuItem( + id = "$pluginId.action", + label = "$escapedName Action", + ), + ) + + override fun onItemClick( + panelId: PanelId, + itemId: String, + windowId: String?, + ) { + println("Panel menu item clicked: ${'$'}itemId for $escapedName") + } + } + menuContribution = contribution + context.registerPanelMenuContribution(contribution) + + workerJob = + context.pluginScope.launch { + println("Full plugin service started for $escapedName ($pluginId)") + while (isActive) { + delay(30_000) + } + } + } + + override fun dispose() { + workerJob?.cancel() + workerJob = null + toolProvider = null + menuContribution = null + println("Disposed full plugin $escapedName ($pluginId)") + } + } + """.trimIndent() + "\n" + } + } + } + + private fun generateTestFile( + packageName: String, + className: String, + pluginName: String, + pluginId: String, + ): String { + val escapedName = escapeString(pluginName) + return """ + package $packageName + + import kotlin.test.Test + import kotlin.test.assertEquals + import kotlin.test.assertNotNull + + class ${className}Test { + @Test + fun testPluginMetadata() { + val plugin = $className() + assertNotNull(plugin) + assertEquals("$pluginId", plugin.pluginId) + assertEquals("$escapedName", plugin.displayName) + } + } + """.trimIndent() + "\n" + } + + private fun generateGitignore(): String = + """ + .gradle/ + build/ + .idea/ + *.iml + .DS_Store + """.trimIndent() + "\n" + + private fun generateReadme( + name: String, + id: String, + template: String, + apiVersion: String, + ): String = + """ + # $name Plugin + + Scaffolded BossConsole plugin project. + + ## Specification + - **Plugin ID**: `$id` + - **Template**: `$template` + - **Target Host API**: `$apiVersion` + + ## Build Instructions + ```bash + ./gradlew build + ``` + + ## Validate & Link + ```bash + boss plugin validate . + boss plugin link . + ``` + """.trimIndent() + "\n" + + private val RESERVED_KEYWORDS = + setOf( + "as", + "break", + "class", + "continue", + "do", + "else", + "false", + "for", + "fun", + "if", + "in", + "interface", + "is", + "null", + "object", + "package", + "return", + "super", + "this", + "throw", + "true", + "try", + "typealias", + "typeof", + "val", + "var", + "when", + "while", + "default", + "switch", + "case", + "const", + "goto", + ) + + fun isReservedKeyword(name: String): Boolean = RESERVED_KEYWORDS.contains(name.lowercase()) + + fun derivePluginId(name: String): String { + val trimmed = name.trim().lowercase() + if (trimmed.contains('.')) { + val cleaned = + trimmed + .replace(Regex("[^a-z0-9._-]"), "-") + .replace(Regex("-+"), "-") + .trim('-', '.') + if (cleaned.isNotEmpty() && cleaned.first().isLetter() && cleaned.contains('.')) { + return cleaned + } + } + val rawSuffix = + trimmed + .replace(Regex("[^a-z0-9-]"), "-") + .replace(Regex("-+"), "-") + .trim('-') + .ifBlank { "custom-plugin" } + return "com.example.$rawSuffix" + } + + fun sanitizePackageIdentifier(rawId: String): String { + val cleaned = rawId.lowercase().replace(Regex("[^a-z0-9]"), "") + val validStart = + if (cleaned.isNotEmpty() && cleaned.first().isDigit()) { + "p$cleaned" + } else { + cleaned.ifBlank { "plugin" } + } + return if (isReservedKeyword(validStart)) "${validStart}_pkg" else validStart + } + + fun sanitizeClassIdentifier(rawName: String): String { + val pascal = + rawName + .trim() + .split(Regex("[^a-zA-Z0-9]")) + .filter { it.isNotBlank() } + .joinToString("") { it.replaceFirstChar { c -> c.uppercase() } } + .ifBlank { "Custom" } + val base = if (pascal.first().isDigit()) "Plugin$pascal" else pascal + return if (base.endsWith("Plugin")) base else "${base}Plugin" + } + + /** + * Prevents catastrophic directory deletion in --force mode by ensuring the directory is not a + * system root, user home, git repository, or arbitrary non-plugin directory. + */ + fun assertSafeToPurge(targetDir: File) { + val canonicalTarget = targetDir.canonicalFile + val userHome = System.getProperty("user.home")?.let { File(it).canonicalFile } + require(userHome == null || canonicalTarget != userHome) { + "Refusing to purge user home directory in --force mode: ${targetDir.absolutePath}" + } + val roots = File.listRoots()?.map { it.canonicalFile } ?: emptyList() + require(roots.none { it == canonicalTarget }) { + "Refusing to purge system root directory in --force mode: ${targetDir.absolutePath}" + } + val gitDir = File(targetDir, ".git") + require(!gitDir.exists()) { + "Refusing to purge directory containing a .git repository in --force mode: ${targetDir.absolutePath}" + } + val hasPluginJson = + File(targetDir, "plugin.json").exists() || + File(targetDir, "src/main/resources/META-INF/boss-plugin/plugin.json").exists() + val hasGradleBuild = + File(targetDir, "build.gradle.kts").exists() || File(targetDir, "build.gradle").exists() + require(hasPluginJson || hasGradleBuild) { + "Refusing to purge non-plugin directory in --force mode: ${targetDir.absolutePath}. " + + "Directory does not contain plugin.json or build.gradle.kts. Clear it manually if intended." + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginValidator.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginValidator.kt new file mode 100644 index 0000000000..1fba5b5cf6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/launchpad/PluginValidator.kt @@ -0,0 +1,635 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.plugin.api.Plugin +import ai.rever.boss.plugin.loader.PluginManifestReader +import java.io.File +import java.io.IOException +import java.net.URLClassLoader +import java.nio.BufferUnderflowException +import java.nio.ByteBuffer +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.JarFile +import java.util.zip.ZipException + +/** + * Validates a plugin source directory or packaged archive (.jar / .zip). + */ +@Suppress( + "ReturnCount", + "TooGenericExceptionCaught", + "LongMethod", + "ComplexMethod", + "NestedBlockDepth", + "TooManyFunctions", +) +object PluginValidator { + private val ID_REGEX = Regex("^[a-zA-Z][a-zA-Z0-9_-]*(?:\\.[a-zA-Z0-9_-]+)+$") + private val MCP_TOOL_REGEX = Regex("^mcp__[a-zA-Z0-9_-]+__[a-zA-Z0-9_-]+$") + private val CLASS_NAME_REGEX = Regex("""^[a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)+$""") + private const val MAX_CLASSFILE_BYTES: Long = 5_000_000L + + fun validate(target: File): ValidationResult { + val checks = mutableListOf() + + if (!target.exists()) { + checks += + ValidationCheck( + name = "target-exists", + passed = false, + message = "Target path does not exist: ${target.absolutePath}", + ) + return ValidationResult(isValid = false, checks = checks) + } + + checks += + ValidationCheck( + name = "target-exists", + passed = true, + message = "Target path exists: ${target.absolutePath}", + ) + + return if (target.isDirectory) { + validateDirectory(target, checks) + } else { + validateArchive(target, checks) + } + } + + /** + * Resolves the target JAR artifact for staging into BossConsole. + * If [inputPath] is already a JAR, returns it. + * If [inputPath] is a project directory, locates the compiled JAR in `build/libs/`. + */ + fun resolveStagingTarget(inputPath: Path): Path { + if (Files.isRegularFile(inputPath) && inputPath.toString().endsWith(".jar")) { + return inputPath + } + val libsDir = inputPath.resolve("build").resolve("libs") + if (Files.isDirectory(libsDir)) { + val candidates = + Files.list(libsDir).use { stream -> + stream + .filter { + val name = it.fileName.toString() + name.endsWith(".jar") && + !name.endsWith("-sources.jar") && + !name.endsWith("-javadoc.jar") && + !name.endsWith("-plain.jar") && + !name.endsWith("-all.jar") + }.toList() + } + if (candidates.size == 1) { + return candidates.single() + } + if (candidates.size > 1) { + // Disambiguate by directory name or pick the newest modified build + val dirName = + inputPath.fileName + ?.toString() + ?.lowercase() + .orEmpty() + val matchingByName = + candidates.filter { + val name = it.fileName.toString().lowercase() + dirName.isNotEmpty() && name.startsWith(dirName) + } + val pool = if (matchingByName.isNotEmpty()) matchingByName else candidates + val newest = pool.maxByOrNull { Files.getLastModifiedTime(it) } + if (newest != null) return newest + } + } + error("No compiled JAR found in target directory. Run './gradlew build' before linking.") + } + + fun toReport( + targetPath: String, + result: ValidationResult, + ): ValidationReport = + ValidationReport( + success = result.isValid, + targetPath = targetPath.replace('\\', '/'), + checksPassed = result.checks.count { it.passed }, + totalChecks = result.checks.size, + failures = result.checks.filter { !it.passed }.map { ValidationFailure(it.name, it.message) }, + checks = result.checks, + ) + + fun readManifestFromJar(file: File): PluginManifest? = + try { + JarFile(file).use { jar -> + val entry = + jar.getJarEntry("META-INF/boss-plugin/plugin.json") ?: return null + val content = + jar.getInputStream(entry).use { DevPluginArtifacts.readBoundedUtf8String(it) } ?: return null + launchpadJson.decodeFromString(content) + } + } catch (_: Exception) { + null + } + + private fun validateDirectory( + dir: File, + checks: MutableList, + ): ValidationResult { + val resourceManifest = File(dir, "src/main/resources/META-INF/boss-plugin/plugin.json") + val rootManifest = File(dir, "plugin.json") + val manifestFile = + when { + resourceManifest.exists() && resourceManifest.isFile -> resourceManifest + rootManifest.exists() && rootManifest.isFile -> rootManifest + else -> null + } + + if (manifestFile == null) { + checks += + ValidationCheck( + name = "manifest-exists", + passed = false, + message = "plugin.json not found in directory: ${dir.absolutePath}", + ) + return ValidationResult(isValid = false, checks = checks) + } + + checks += + ValidationCheck( + name = "manifest-exists", + passed = true, + message = "plugin.json found at ${manifestFile.relativeTo(dir).path.replace('\\', '/')}", + ) + + val jsonContent = + try { + manifestFile.readText() + } catch (e: IOException) { + checks += + ValidationCheck( + name = "manifest-readable", + passed = false, + message = "Unable to read plugin.json: ${e.message}", + ) + return ValidationResult(isValid = false, checks = checks) + } + + val manifest = parseManifest(jsonContent, checks) ?: return ValidationResult(isValid = false, checks = checks) + validateManifestFields(manifest, checks, jsonContent) + + return ValidationResult(isValid = checks.all { it.passed }, checks = checks) + } + + private fun validateArchive( + file: File, + checks: MutableList, + ): ValidationResult { + val archiveData = + try { + readArchive(file, checks) + } catch (e: Throwable) { + checks += + ValidationCheck( + name = "archive-readable", + passed = false, + message = "Failed to read archive: ${e.message ?: e::class.simpleName}", + ) + return ValidationResult(isValid = false, checks = checks) + } ?: return ValidationResult(isValid = false, checks = checks) + + val manifest = + parseManifest(archiveData.manifestContent, checks) + ?: return ValidationResult(isValid = false, checks = checks) + validateManifestFields(manifest, checks, archiveData.manifestContent) + + // Bytecode verification for entrypointClass + val entrypointBytecodePath = manifest.mainClass.replace('.', '/') + ".class" + val entrypointExists = archiveData.hasEntry(entrypointBytecodePath) + checks += + ValidationCheck( + name = "bytecode-entrypoint", + passed = entrypointExists, + message = + if (entrypointExists) { + "Bytecode entry '$entrypointBytecodePath' verified in archive" + } else { + "Bytecode entry '$entrypointBytecodePath' not found in archive" + }, + ) + + if (entrypointExists) { + val implementsPlugin = verifyImplementsPlugin(file, manifest.mainClass, archiveData) + checks += + ValidationCheck( + name = "bytecode-implements-plugin", + passed = implementsPlugin, + message = + if (implementsPlugin) { + "Entrypoint class '${manifest.mainClass}' implements ai.rever.boss.plugin.api.Plugin" + } else { + "Entrypoint class '${manifest.mainClass}' must implement ai.rever.boss.plugin.api.Plugin" + }, + ) + } + + return ValidationResult(isValid = checks.all { it.passed }, checks = checks) + } + + private data class ArchiveData( + val manifestContent: String, + val hasEntry: (String) -> Boolean, + val entrypointClassBytes: ByteArray?, + ) + + private fun readArchive( + file: File, + checks: MutableList, + ): ArchiveData? = + try { + JarFile(file).use { jarFile -> + val metaInfEntry = jarFile.getJarEntry("META-INF/boss-plugin/plugin.json") + val rootEntry = jarFile.getJarEntry("plugin.json") + + if (metaInfEntry == null && rootEntry != null) { + checks += + ValidationCheck( + name = "manifest-exists", + passed = false, + message = + "plugin.json found at root of archive; must be located at " + + "META-INF/boss-plugin/plugin.json", + ) + return null + } + + val manifestEntry = metaInfEntry + if (manifestEntry == null) { + checks += + ValidationCheck( + name = "manifest-exists", + passed = false, + message = "plugin.json not found in archive: ${file.name}", + ) + return null + } + + checks += + ValidationCheck( + name = "manifest-exists", + passed = true, + message = "plugin.json found in archive", + ) + + val manifestContent = + jarFile.getInputStream(manifestEntry).use { DevPluginArtifacts.readBoundedUtf8String(it) } + if (manifestContent == null) { + checks += + ValidationCheck( + name = "manifest-valid", + passed = false, + message = + "plugin.json exceeds maximum allowed size limit of " + + "${DevPluginArtifacts.MAX_MANIFEST_BYTES / 1024} KB", + ) + return null + } + val allEntries = + jarFile + .entries() + .asSequence() + .map { it.name } + .toSet() + + val mainClass = + Regex(""""(?:mainClass|entrypointClass)"\s*:\s*"([^"]+)"""") + .find(manifestContent) + ?.groupValues + ?.get(1) + + val entrypointBytes = + if (mainClass != null) { + val entrypointPath = mainClass.replace('.', '/') + ".class" + jarFile.getJarEntry(entrypointPath)?.let { entry -> + if (entry.size > MAX_CLASSFILE_BYTES) { + null + } else { + jarFile.getInputStream(entry).use { stream -> + val bytes = stream.readNBytes((MAX_CLASSFILE_BYTES + 1).toInt()) + if (bytes.size > MAX_CLASSFILE_BYTES) null else bytes + } + } + } + } else { + null + } + + ArchiveData( + manifestContent = manifestContent, + hasEntry = { entryName -> entryName in allEntries }, + entrypointClassBytes = entrypointBytes, + ) + } + } catch (e: ZipException) { + checks += + ValidationCheck( + name = "archive-valid", + passed = false, + message = "Corrupt or invalid ZIP/JAR archive: ${e.message}", + ) + null + } + + /** + * Verifies that [mainClass] implements [Plugin]. + * Uses [Class.forName] with initialize = false to avoid running static initializers (). + * Falls back to inspecting classfile bytes for direct interface declaration if dependencies are missing. + */ + private fun verifyImplementsPlugin( + jarFile: File, + mainClass: String, + archiveData: ArchiveData, + ): Boolean { + // Fast-path & isolation safety: check direct interface implementation from classfile bytes first + // to avoid parent-first delegation falsely loading a host-side class with the same name. + val classBytes = archiveData.entrypointClassBytes + if (classBytes != null && checkDirectInterfaceImplementation(classBytes)) { + return true + } + + return try { + val parentLoader = Plugin::class.java.classLoader + // The classloader holds the jar open (notably on Windows), so it is + // closed as soon as the assignability question is answered. + URLClassLoader(arrayOf(jarFile.toURI().toURL()), parentLoader).use { urlClassLoader -> + // initialize = false ensures static initializers () are never executed + val clazz = Class.forName(mainClass, false, urlClassLoader) + Plugin::class.java.isAssignableFrom(clazz) + } + } catch (_: Throwable) { + // Manual classfile fallback when classloading fails due to external dependencies. + // Note: Manual classfile parser supports direct implementations of ai.rever.boss.plugin.api.Plugin. + if (classBytes != null) { + checkDirectInterfaceImplementation(classBytes) + } else { + false + } + } + } + + /** + * Parses the constant pool and interfaces of [classBytes] to determine if it directly implements + * ai.rever.boss.plugin.api.Plugin. + * + * Note: Manual classfile parser supports direct implementations of + * ai.rever.boss.plugin.api.Plugin when classloading is unavailable. + */ + internal fun checkDirectInterfaceImplementation(classBytes: ByteArray): Boolean { + if (classBytes.size < 10) return false + return try { + val buffer = ByteBuffer.wrap(classBytes) + if (buffer.int != 0xCAFEBABE.toInt()) return false + buffer.short // minor + buffer.short // major + val cpCount = buffer.short.toInt() and 0xFFFF + val strings = arrayOfNulls(cpCount) + val classRefs = IntArray(cpCount) + + var i = 1 + while (i < cpCount) { + when (buffer.get().toInt() and 0xFF) { + 1 -> { // Utf8 + val length = buffer.short.toInt() and 0xFFFF + val bytes = ByteArray(length) + buffer.get(bytes) + strings[i] = String(bytes, Charsets.UTF_8) + } + + 7 -> { // Class + classRefs[i] = buffer.short.toInt() and 0xFFFF + } + + 8 -> { // String + buffer.short + } + + 3, 4 -> { // Integer, Float + buffer.int + } + + 5, 6 -> { // Long, Double (takes 2 CP slots) + buffer.long + i++ + } + + 9, 10, 11 -> { // Fieldref, Methodref, InterfaceMethodref + buffer.short + buffer.short + } + + 12 -> { // NameAndType + buffer.short + buffer.short + } + + 15 -> { // MethodHandle + buffer.get() + buffer.short + } + + 16 -> { // MethodType + buffer.short + } + + 17, 18 -> { // Dynamic, InvokeDynamic + buffer.short + buffer.short + } + + 19, 20 -> { // Module, Package + buffer.short + } + + else -> { + return false + } + } + i++ + } + + buffer.short // access flags + buffer.short // this class + buffer.short // super class + val interfacesCount = buffer.short.toInt() and 0xFFFF + repeat(interfacesCount) { + val ifaceRef = buffer.short.toInt() and 0xFFFF + val nameIndex = classRefs.getOrNull(ifaceRef) ?: 0 + val ifaceName = strings.getOrNull(nameIndex) + if (ifaceName == "ai/rever/boss/plugin/api/Plugin") { + return true + } + } + false + } catch (_: BufferUnderflowException) { + false + } catch (_: IndexOutOfBoundsException) { + false + } catch (_: Exception) { + false + } + } + + private fun parseManifest( + jsonContent: String, + checks: MutableList, + ): PluginManifest? = + try { + val manifest = launchpadJson.decodeFromString(jsonContent) + checks += + ValidationCheck( + name = "manifest-json-valid", + passed = true, + message = "plugin.json parsed successfully", + ) + manifest + } catch (e: Exception) { + checks += + ValidationCheck( + name = "manifest-json-valid", + passed = false, + message = "Malformed plugin.json: ${e.message ?: "invalid JSON"}", + ) + null + } + + private fun validateManifestFields( + manifest: PluginManifest, + checks: MutableList, + rawManifestJson: String? = null, + ) { + // ID format (supports reverse-domain dotted IDs e.g. com.example.tool matching PluginManifestReader) + val idValid = ID_REGEX.matches(manifest.pluginId) + checks += + ValidationCheck( + name = "id-format", + passed = idValid, + message = + if (idValid) { + "Plugin ID '${manifest.pluginId}' follows reverse domain notation" + } else { + "Plugin ID '${manifest.pluginId}' must follow reverse domain notation " + + "with at least one dot (e.g. com.example.my-tools)" + }, + ) + + // Version SemVer format + val versionValid = SemVerValidator.isValid(manifest.version) + checks += + ValidationCheck( + name = "version-format", + passed = versionValid, + message = + if (versionValid) { + "Plugin version '${manifest.version}' is valid SemVer" + } else { + "Plugin version '${manifest.version}' must follow SemVer (e.g. 1.0.0)" + }, + ) + + // apiVersion compatibility + val minApiValid = + SemVerValidator.isValid(manifest.apiVersion) && + SemVerValidator.isCompatible(manifest.apiVersion, HostMeta.CURRENT_API_VERSION) + checks += + ValidationCheck( + name = "min-api-version", + passed = minApiValid, + message = + if (minApiValid) { + "apiVersion '${manifest.apiVersion}' is compatible " + + "(host: ${HostMeta.CURRENT_API_VERSION})" + } else { + "apiVersion '${manifest.apiVersion}' is incompatible or exceeds " + + "host API version '${HostMeta.CURRENT_API_VERSION}'" + }, + ) + + // mainClass format + val entrypointValid = CLASS_NAME_REGEX.matches(manifest.mainClass) + checks += + ValidationCheck( + name = "entrypoint-class", + passed = entrypointValid, + message = + if (entrypointValid) { + "Entrypoint class '${manifest.mainClass}' is a valid fully-qualified class name" + } else { + "Entrypoint class '${manifest.mainClass}' must be a valid fully-qualified class name" + }, + ) + + // Permissions registry check: permissions correspond to host RBAC permissions + // (e.g. plugins.create, secret.read) + val permissionsToCheck = manifest.requiredPermissions.ifEmpty { manifest.permissions } + val invalidPermissions = permissionsToCheck.filter { !HostMeta.isValidPermissionIdentifier(it) } + val permissionsValid = invalidPermissions.isEmpty() + checks += + ValidationCheck( + name = "permissions", + passed = permissionsValid, + message = + when { + !permissionsValid -> { + "Invalid permission format: ${invalidPermissions.joinToString(", ")}. " + + "Permissions must follow RBAC identifier format (e.g. 'plugins.create', 'secret.read')" + } + + permissionsToCheck.isEmpty() -> { + "Declared permissions list is empty (accessible to all authenticated users)" + } + + else -> { + "All declared permissions (${permissionsToCheck.size}) follow valid RBAC identifier format" + } + }, + ) + + // MCP tools check + val invalidTools = + manifest.mcpTools.filter { tool -> + !MCP_TOOL_REGEX.matches(tool.name) || tool.description.isBlank() + } + val toolsValid = invalidTools.isEmpty() + checks += + ValidationCheck( + name = "mcp-tools", + passed = toolsValid, + message = + if (toolsValid) { + if (manifest.mcpTools.isEmpty()) { + "No MCP tools declared (optional)" + } else { + "All ${manifest.mcpTools.size} MCP tool declarations are valid" + } + } else { + "Invalid MCP tool declarations: ${invalidTools.map { it.name }}" + }, + ) + + // Host loader manifest contract check + var hostValid = true + var hostMsg = "Manifest conforms to host PluginManifestReader contract" + try { + val hostManifest = + PluginManifestReader.parseManifest( + rawManifestJson ?: launchpadJson.encodeToString(manifest), + ) + PluginManifestReader.validateManifest(hostManifest) + } catch (e: Exception) { + hostValid = false + hostMsg = "Host manifest validation failed: ${e.message ?: "invalid"}" + } + checks += + ValidationCheck( + name = "host-manifest-contract", + passed = hostValid, + message = hostMsg, + ) + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt index 7aa6916707..3b3654bd9e 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/startup/CliBootstrap.kt @@ -41,7 +41,7 @@ object CliBootstrap { */ fun isHeadlessCli(args: Array): Boolean { val firstNonFlag = args.firstOrNull { !it.startsWith("-") }?.lowercase() - return firstNonFlag in setOf("status", "doctor", "mcp", "completion") || + return firstNonFlag in setOf("status", "doctor", "mcp", "completion", "plugin") || (args.isNotEmpty() && args.all { it in setOf("-h", "--help") }) } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt index e983cc75ea..224f5557c1 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/OsOpenArguments.kt @@ -37,7 +37,7 @@ internal object OsOpenArguments { * double-open, which is easy to miss and hard to attribute. */ internal val CLI_SUBCOMMANDS = - setOf("url", "workspace", "file", "folder", "terminal", "status", "doctor", "mcp", "completion") + setOf("url", "workspace", "file", "folder", "terminal", "status", "doctor", "mcp", "completion", "plugin") /** What a path on disk is, for deciding which deep link to build. */ internal enum class OpenTargetKind { FILE, DIRECTORY, ABSENT } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt index 02d83d7d5a..2838a5b80d 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/SingleInstanceManager.kt @@ -19,6 +19,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import java.io.InputStream +import java.net.ConnectException import java.net.InetAddress import java.net.InetSocketAddress import java.net.StandardProtocolFamily @@ -73,6 +74,29 @@ internal const val VERB_MCP_LIST = "MCP_LIST" /** Asks the running instance to invoke an MCP tool. */ internal const val VERB_MCP_INVOKE = "MCP_INVOKE" +/** Asks the running instance to reload a plugin in development mode. */ +internal const val VERB_PLUGIN_DEV_RELOAD = "PLUGIN_DEV_RELOAD" + +sealed interface ReloadResult { + data object Success : ReloadResult + + data class HostOffline( + val message: String = "BossConsole is not running", + ) : ReloadResult + + /** + * The host is running but did not confirm the reload within the client budget. The reload + * may still be in progress on the host; the staged JAR is on disk for the next launch. + */ + data class TimedOut( + val message: String, + ) : ReloadResult + + data class Failed( + val reason: String, + ) : ReloadResult +} + internal const val RESPONSE_OK = "OK" internal const val RESPONSE_PONG = "PONG" internal const val RESPONSE_REJECTED = "REJECTED" @@ -99,6 +123,11 @@ private const val MCP_INVOKE_TIMEOUT_MS = 60000L // still close a genuinely wedged connection rather than race it. private const val OPEN_ACTION_TIMEOUT_MS = 5000L +// A dev reload on the host unloads the running instance and installs fresh bytes. +// This is the client budget: above anything a healthy reload spends, below the +// 60s server connection budget the verb gets from isLongerBudgetCandidate. +internal const val PLUGIN_DEV_RELOAD_TIMEOUT_MS = 45_000L + /** * Ceiling on a single request. Bounds what one caller can make the app buffer, * sized to accommodate Base64-encoded tool arguments and payloads. @@ -109,8 +138,8 @@ internal const val MAX_REQUEST_BYTES = 1024 * 1024 internal const val MAX_ARGUMENT_BYTES = 768 * 1024 - 1024 internal const val MAX_TOOL_NAME_LENGTH = 256 -/** A response is one short word; nothing legitimate approaches this. */ -private const val MAX_RESPONSE_BYTES = 256 +/** A response ceiling expanded to 1024 bytes to support diagnostic dev reload error payloads. */ +internal const val MAX_RESPONSE_BYTES = 1024 /** Ceiling on data responses (status, MCP tool list, Base64 tool invocation output). */ private const val MAX_DATA_RESPONSE_BYTES = 4 * 1024 * 1024 @@ -220,8 +249,11 @@ internal data class SingleInstanceRequest( * * The line is ` ...` */ +@Suppress("ReturnCount") internal fun parseRequestLine(line: String): SingleInstanceRequest? { - val parts = line.trim().split(' ', limit = 5) + val trimmed = line.trim() + val parts = trimmed.split(' ', limit = 5) + if (parts.size < 3 || parts[0] != PROTOCOL_VERSION) return null val token = parts[1] @@ -246,6 +278,15 @@ internal fun parseRequestLine(line: String): SingleInstanceRequest? { parseMcpInvokeRequest(token, parts) } + VERB_PLUGIN_DEV_RELOAD -> { + val pluginId = parts.getOrNull(3).orEmpty() + if (validPluginDevId(pluginId)) { + SingleInstanceRequest(token, VERB_PLUGIN_DEV_RELOAD, DeepLinkOrigin.OPERATOR_CLI, null, pluginId) + } else { + null + } + } + else -> { null } @@ -255,6 +296,16 @@ internal fun parseRequestLine(line: String): SingleInstanceRequest? { private fun validMcpToolName(toolName: String): Boolean = toolName.length in 1..MAX_TOOL_NAME_LENGTH && toolName.none { it.isWhitespace() || it.isISOControl() } +/** + * A plugin id crosses the wire as a bare token and the host joins it straight onto + * the dev staging root, so it must never carry a path separator or traverse out of it. + * Whitespace cannot survive the wire format anyway; the separators and `..` can. + */ +private fun validPluginDevId(pluginId: String): Boolean = + pluginId.length in 1..MAX_TOOL_NAME_LENGTH && + pluginId.none { it.isWhitespace() || it.isISOControl() || it == '/' || it == '\\' } && + pluginId != ".." && pluginId != "." + private fun parseMcpInvokeRequest( token: String, parts: List, @@ -819,6 +870,43 @@ private fun buildMcpInvokeResponse( } } +private fun buildPluginDevReloadResponse( + pluginId: String, + handler: ((String) -> Boolean)?, +): String { + if (pluginId.isBlank()) { + return "RELOAD_FAILED Missing pluginId parameter" + } + val result = + runCatching { + checkNotNull(handler) { "Development plugin reload is not available in this host" }.invoke(pluginId) + } + return result.fold( + onSuccess = { success -> + if (success) { + "RELOAD_OK $pluginId" + } else { + "RELOAD_FAILED Failed to reload plugin $pluginId" + } + }, + onFailure = { error -> + val rootCause = + generateSequence(error) { it.cause?.takeIf { cause -> cause !== it } } + .take(15) + .last() + val errorName = rootCause::class.simpleName ?: "Error" + val errorDetail = rootCause.message ?: error.message ?: "No detailed cause" + val sanitizedMessage = + "$errorName: $errorDetail" + .replace(Regex("[\\r\\n]+"), " ") + .take(400) + val recoveryWarning = + if (error.suppressedExceptions.isNotEmpty()) "Rollback failed; check the host log. " else "" + "RELOAD_FAILED $recoveryWarning$sanitizedMessage" + }, + ) +} + internal fun encodeMcpTools(tools: List): String = JsonArray( tools.map { registeredTool -> @@ -959,6 +1047,9 @@ object SingleInstanceManager { /** Test seam / host hook for MCP tool invocation response. */ internal var mcpInvokeHandlerOverride: (suspend (String, String) -> McpToolResult)? = null + /** Test seam / host hook for dev plugin reload response. */ + internal var pluginReloadHandlerOverride: ((String) -> Boolean)? = null + @Volatile private var isListening: Boolean = false @@ -1102,7 +1193,11 @@ object SingleInstanceManager { private val isLongerBudgetCandidate: (SingleInstanceRequest) -> Boolean get() = { request -> - (request.verb == VERB_LLM_TOKEN || request.verb == VERB_MCP_INVOKE) && presentsLiveToken(request) + ( + request.verb == VERB_LLM_TOKEN || + request.verb == VERB_MCP_INVOKE || + request.verb == VERB_PLUGIN_DEV_RELOAD + ) && presentsLiveToken(request) } /** @@ -1121,6 +1216,7 @@ object SingleInstanceManager { * back. A request that does not present the live token is refused here, * before its contents mean anything. */ + @Suppress("TooGenericExceptionCaught", "CyclomaticComplexMethod") private fun responseFor(request: SingleInstanceRequest?): String { if (request == null || !presentsLiveToken(request)) { logger.warn(LogCategory.SYSTEM, "Refused a malformed single-instance request or missing channel token") @@ -1169,6 +1265,10 @@ object SingleInstanceManager { buildMcpInvokeResponse(toolName, argsJson, mcpInvokeHandlerOverride) } + request.verb == VERB_PLUGIN_DEV_RELOAD -> { + buildPluginDevReloadResponse(request.toolName.orEmpty(), pluginReloadHandlerOverride) + } + else -> { logger.warn(LogCategory.SYSTEM, "Refused a single-instance request BOSS does not serve") RESPONSE_REJECTED @@ -1407,6 +1507,69 @@ object SingleInstanceManager { return response == RESPONSE_OK } + /** + * Dispatches dev reload signal for [pluginId] to the running BossConsole instance + * with synchronous request-response verification and timeout handling. + * + * A response that never arrives is split into its two causes: nothing answering the + * channel means the host is offline, while a host that answers a probe but not the + * reload is busy, and the staged JAR is still picked up at the next launch. + */ + @Suppress("ReturnCount") + fun reloadDevPlugin( + pluginId: String, + timeoutMs: Int = PLUGIN_DEV_RELOAD_TIMEOUT_MS.toInt(), + ): ReloadResult { + if (!validPluginDevId(pluginId)) { + return ReloadResult.Failed("Invalid plugin id for dev reload: '$pluginId'") + } + val target = + SingleInstanceFiles.read() + ?: return ReloadResult.HostOffline("BossConsole is not running.") + val message = "$PROTOCOL_VERSION ${target.token} $VERB_PLUGIN_DEV_RELOAD $pluginId" + return try { + val response = + SingleInstanceWire.exchange( + target, + message, + timeoutMs = timeoutMs.toLong(), + maxResponseBytes = MAX_RESPONSE_BYTES, + ) ?: return if (SingleInstanceWire.respondsToPing(target)) { + ReloadResult.TimedOut( + "BossConsole is running but did not confirm the reload within $timeoutMs ms; " + + "it may still be reloading", + ) + } else { + ReloadResult.HostOffline("BossConsole is offline or the channel stopped answering") + } + + when { + response == "RELOAD_OK $pluginId" -> { + ReloadResult.Success + } + + response.startsWith("RELOAD_FAILED") -> { + ReloadResult.Failed(response.removePrefix("RELOAD_FAILED").trim()) + } + + else -> { + ReloadResult.Failed("Malformed IPC response: $response") + } + } + } catch ( + @Suppress("SwallowedException") e: ConnectException, + ) { + logger.debug( + LogCategory.SYSTEM, + "IPC host connection refused; host is offline", + mapOf("pluginId" to pluginId), + ) + ReloadResult.HostOffline("BossConsole is offline (connection refused)") + } catch (e: Exception) { + ReloadResult.Failed("IPC communication error: ${e.message}") + } + } + /** * Start listening for URLs from new instances. * @@ -1431,6 +1594,7 @@ object SingleInstanceManager { statusProviderOverride = null mcpListProviderOverride = null mcpInvokeHandlerOverride = null + pluginReloadHandlerOverride = null try { serverChannel?.close() diff --git a/composeApp/src/desktopMain/resources/launcher/gradle-wrapper.jar b/composeApp/src/desktopMain/resources/launcher/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..eddabd2eef8d94a5437d6168ff9c87a78ff725b3 GIT binary patch literal 47505 zcma%jV|XRZx@BzJPRF)w+qP|W2RrDP9UC1d9oxo^)3I%LIQh<*=g!Qz_k45q^VI&e z|5VkgwQ8;Rt*tBv4uJsz0|NsB0z&#Z{?7*m1QtX=LS2MGMp2SUUPeqpQB6Wa9TEie zub-^z>bb3QVg*ju^jKS3o#9H#w4Yxz1*n>pYH+2nC3dC@ic(OUh@sI7>n^@O3t+EN zk19TR2&69-M23X8{h9JYx|8)kwwf7ttr>teD4+VN#nkbK$s(IG`^odY38j0~G5LYI zE8yj!-3t3WJpbc*GP8f1Ijv!GZFxNt(Cq4DxZU@%dVh&ur@bEGnl2N^*QNXDgt%%uIzL8-|f9*0a_P$gWq1W= zyYFqsd}OSk24kb~1dN}B%z?^{HGmwKoogz&O?>^nlNT;9zKwXe(^*#}CA6|3JU~$) z84gW6*^!J(I2cJ6Fex`F@*8Z;s#mTo^y2AJ6hSf>Ei3lYhvt>4{wrqH*`8wlt+NqV zs$Y#ZDUzSWF!beISEBi0Dvx#amw4A=5p>tM)l(wMg*GU=hp|-Z=aZM_pw^OegdgFE z#1Jtd_&p~_;Lb@JjM5MZdJErBWf7~hq^IxX89(}?*<2v)uDSTyr#g{7fM4R;@KjPU zef+&aPhcAskT5|z_09<(`3G^SKwJ0e=Q(TjU}<2E7jh(ZoiwT{!}jl%GU(rNo2?a! zx2+TFX}Pt%EZ7ohNMI$bpk|IVcQ3Z2tWLJSZtq)*Im<#WBDYEfci;r(!~8KiUAI2I z+)9QJ;|lF-J*nrrVRIf{Rt}tBY`7YBW#R+!Qox8y99}8lf<@)9znd`>8Q;dY znEDDc?e6`E=jWxW%O= z*dn!&=MGIsRTcKy#$f?nc7NBdssxcHDt6p!g8h@bt@_P63RGK`SeA81RG5nyyn|pn zh5?evjH@qk|v=A$Ff0_lB8|p@3G{6%UYG`sujf7mV;X1<41iQ?RY8pV7 z+JTVijVDHlCoGIu&$AT+dB^5QPcKo%0%E$4uIC6MXfn^S5s%Or=V!~H;WD2>O)~K>|X>YMP=}Y_0pejZk-6r@uWi|+^N62^lykrsvI-LZ#)+m^*{7pxuE$-YJPa6ES98M;^-kOi6XZp$Mun zVh_^?Hp<}gH$rrm9(0RoI9SWRQ6R)wVQt0P3)HH@+_$;Wu?Pdh#`*-jv&l=#aB#ZB z__a1vF1``N!=i=c>_*5tSi+du{D=L>p#AE6M9%CROw=u892xWbhBJ2&ZWOPUu9e_t z`J0llKMY7mQOc(WraFZmW=wk^KbcDk)u1}fF!vO9a$)!UcLP)4H1`%4xgRqS0K?Ri z5wDR#A&14*d%ZEfJ%yaM!xA9$SjkFRTM(E=VBF=fl`Xebo{4H-4hj0}f`xQV%Siw~ zm)X(4E#M~0rjvozMFh8$OtrMtNIwdWI#K9mA^S9Y`%(O7+DH&z2BPw}+FP|N{8`yc ztMq*2M?9lMzlQKSXTlP7_S}q6O5>aSLKTkPfx$(5-5iMGcgSoF6$&wzunij_p=r=9 zULJ3>$)nnNCaOIhR<^3ydE|tmD2_eJi2rKJ>=4lfdXl%T^<`2cL8Qnr#g}u6)mqEfkdy^j(pd_;1LfQq)~T z)#*RRvAV3a;5g%FsE=#2$4c)4WyUl~Bx{f3L=Y&s6_!#gFQs!SM z%Ptu1IMS7C?+LldgwXHRxHrmZ7c|W9txqXT!D^j9u-AN|Y|OWq2SC{L<-cTTicAmi z_r#W74+DHIHg*akRkcJKQULezAc{~%>2%5wLQ>VNv3usWH7RnZ2Gz-YT0A%><>0c`H5JO8&DXi*zR64@Cim$sxd2bU<1bGfQN zYN$wwe1Suk{w@!&Grd0uH@kI*wheyqH}Pu37`unlXJ3eVY_&RLtw?MtwCC}kX& z2r=ymZ+8nA9_W&_-!Uk78%AX;0kBIr^@FWC=Iq?}st>4E&+p_%duBh3kVNp=krEPD z)GOWz8oLGhf-icgv}Z?)m7f&8FU^%9YU6rK!9w3vM<_rm+D;$*BFzlm^yg?%23uAQ z%KeUiUgps!x2o$8_73aGGei+l?ijb$qk0&_pcxE$L&m{m1E)z5{%6fgW`S-VGaRav z!S?Iw_l z8bcI?Ymm-FZKn!Np;!~O96H)0IY%e2ReRm7kG<82Fn{mNbwWMVA4 z>uZy@Ye%>7g;Xba{0<$EH@{`|xhnAW31=;CMC_|9j?M+?>Ej*_aqKS9>ogRu%(R<^ z8J;b1?=_I671Vk@wUgy9Y-KNgni)d}*j0y<^urrM2Uk2lFt7uFt`+!g{6?nxn8HDA z-|mcYugdaGsE%N=JvnV*xpYv3#ROT8=BsCVx@0{J239XjS;u0Ma+!u+Fwr5ij=6m0 zLSvIxxB1C7^gHZ#DcL&5(^lO35rh)6% zTsaD~2LM9BOvklgrH#EyzGJ%@S_@lewSL>+u5R+Tiq+s>wC&&!bZ{TdFdO)hkb5-6 z$JW2#Z|Z!%lkE+Ji(AJ*TFz!!5aIfBcEyHaG53g88ae_isos&=hRdKu{(IgmZ3Gds z7k(3>R}TbXV~wbz&J~3lCtMn+1npudNl-F=qB2KmbKczrin|qq(zUiV=mz!5jQt(W z4osJngz2I~I*eB?O3AP2V$NNllivTjjiDCk>V%*qVl&IrYG0a8ch#hengcSQ0H~+K zBrZ5)DU<3ZAI!Gpd$pCpi>TAd%xh;}9a74VXzmbM7C9K#VsIv!z}_@E{+d_U`?Nq% zi@u}DiWhyB4y$-r=+xk@;E9jM)7*`fPg)%mEu3MTd`DT51r_)uS|F(! zH_LOl6m)}??`_oHJ&>D)Os*^s<836X+kL$G%25M*fJ%&Z1B&T zx8I#PIpI+RmNaLK`Fp&CnIwK8BSBAd1%72kS{KygNw=~bG)!%CA<;1+2uK$d2#E5( z^@|w)w_j8cQIwICP*Z1Ako+&t$S^qx7s8AJvE@f{8IQdTeE6YPrV5cF8dS5|VoNd< zANp`#(YR!CkO|d!PGixcChz$md$u3@%N8#7%MI==THk`Dlx!B6XY2sEDGUZrd)9ZV z`Neo82#w}>2PYcZeDI8fty)z?U1X_n8XA^i5hNk;Ku&STgIxXgtP~=n*gS)-O^6j7 z-R8FH?Zu`x8u%&h7qGwPXFENvoAPODTR+FYpC8NT{G42^n5yv;0}-EEv48O`iX+}!?a@(PLya{a<6*$#GkW&+gS*DX|y z3T|bC+7j^vr8&A+T^EY8jqRDWGEpR0uQE9h$nPLQ$=sk4R$IL5iqjzJwhACddroj?Br~lT+S2>vo<5ZJwlL{#iW=$)A*+<(iFSGBZie!dF^3DNh z?&VkWO=0E?+KTHCe{4{tuuxRaV9(2n@)ICSnZ2(;4v}b^r=)pTAhI4=3C5^0CHG3> z5h}3Rg{iTfU#*m;NN8>F%TAm=@&ZkrpGX$TSo?}+I$VpJo~E7Htc`3-$LXM;rG9lE z72K^9V-I@?9QApE5W?Uzl-x0%^3DO@2O@e?1gXf|5|#& zaPJKC&qP7%bNu_I|MIs>uk=5yw}q;n61oV+J0O+OAx&;v;wres&^q5jLs*uj3x$aS zGMW-4nrUu5pKw|3SGz<^!a(je)0GZ68as>N3*RexSA-RI0-B-a6wht;CExAj>+9`3 z-&b6E=8n}>KTY4lg_b&U0yR3jp;S#E!jhxxcj#Giw&7vWgRckGs5^*u)}A0>LkQ!I{?^dBG~R6ZVjsS)_$H=G&-0-z@Z^T z;gk9U-en=IA!lcEox4>qLD#kR4LdF1skHspDSpcDCtJ+ybScF*(D?T!p(2YlA*vwq zAJ4-kR{A+sw33EEiS2Z`n_qo}aC3;lJcfq<;{niS?5-}rPRGD7m$_b3hl5hZ5!aN! zPLy%q0TY}4dDKRy07;H;-8fl_tf4Q;8~MGZk_=Na8%JZtZH-%UH;0oEvTv6|jv9#5 zX8r@RdYCzRycx0WuBIz*2gC5y z)@!vL!f>yhNfq-Oz{~es!{ua*4Ca!K4t`s7c z?iQ~9gtptia7l`q!C%-Gm`i0ekj*E1s%i;tDz+ew*Y5eDj+TqZT=3(@w4{BmzLsxw z!coPP;`-z1E39lmq)-pBMaMZ-6|l&KD!tY3aLsLct@ZYHshJprsG#TS`shgFPp8V^ zVpn`qovk)vp|y6`lB+%uZx_8!7sE(RD4jQnwOblArJa`ci^wW`^a7L@xC*=OWa6+M zWodt%>31m$zsTBhf2>XGc19kgP`HQ`g8jk$!D5TuerlYMuKnf|${e0*W9mQUHk_Ev z1}3`IX4Nk_!^H+3Mcz{yB=h>ksBn$HPmbW(DR831#0o6v_VR)8<~YCJMRB4}c!C+% zE(&$bqy;^T&;?C?Oe2OLHskKJzIx*E&hoNHm$F2u!;$|m{&DwYVi1pqDLHKXV@l)k z36#r#G4nvPjNrHa_$71n%MJ0`6v*1wky|(>O$xHJ3V1>n3+s8hR;IV+8_`;T4HS#? zDo_Bx04bO z85- z>;Zba zyPAjT{}#u8!EU35gBrRPMj#^z{$d`Qa77h|qZ+tOsx>Oi09Oxo`F3$}U#JV9^|yXv z%A}*E7r7^|M~P73<_q|I9kZF`ywlWE;ry@m88DGWrtFD}gPfNvw_Lvqx2b@~_kB8$ zv}^c&Bc+_zj2AH)7YDT;78bHIoXODzI*o0P&RWg#jg~2pza30qE?_b$U8NSvMOWSN zIHb~7wgBX;vYiEs-UbVuI7t>P33PF2i&FtNo7Ol`xJ0n4`DNxKG0`#6u{1%FJve(7 z6()8&O^z@Cm+@+II!-2hvI<;Z&&BeE79GZ;678KP^0R^Hc#^~cNY23 zXU4@&2L&gWb_*TPRyqoIHurXobs2qgWjGU)o6xR;%r?K6?I~q$f1y6EG_UQOemWI# z;9LlKge2*183ODujxR$J&WdACrinw@RlLxSPDp0TS-stiKl>_9aEFlWBz z4o))x#Y{SoFc;HF37qwrWdsFTPqL2&u$$VQ%699A5}Gdrv*zqU&NrNW!e4V($Q{Eb z@C3EVde^8R$2|_zL1pX@TNlTcLk>Go%~+9F$r95adgKnGo+d#?3nbB8W3H@vIViDl zNdLBOVr-}K8UauAi=yA$+Vt`1QsscTU*%lr74*hlOAW)u+*ewJHiY{qRFpgv-n!X6 z_;zwk`r8TBo5iM0`)nEPT<5(udKJd|fU~VoZ#uv+S%6UAl6zHFp(6!iA&>i7nBdwR zVizF<+MXpZIf(@zQ?6-PqZUpc89rh>{hU0^U+qm=&FW4eMb^^A)OYC1N8i_gZ2}ej=NJx2ZmfOOT*an@)`UG zxzAZ?M}$(t(9tj7<>m>lztI~ccK3!lUGWUVI7hb(jhyn=Db4=)<98-}DD~I5R(Hl! zu=tcAAoSmzYk~jdT+2B+c{%=5ivB51YVIcP7XNavQ#5tFFc$FEsg9Lp)X1_y&>(5_ zm}QV7ql95XLL;J%DXim{al&MmWJ;wyH1ssGQJ^snbuK-`JJ)2kkxp(l7LMsH7%l^D zdcEGjpSO^m8N$PcF4Z-{ISCPcj;hrT{jGA}&YigL|4irl!)-zNk2v29MDpKk&YYc)@pPt9^quC8sB_uIJ0dnBf_RA0`WT0W5c1_q%Q*_If_syb&1MJXv$6n=m^YAi88`5kqcgxE zHT!&IrQGr=Hag$yPP;YB)|O^{4_bY7`(em%E`uHVCOB7!?WmkF4aKx2aAp$zOmB!p zZ{sfS^NgnElY1m^zXJ#n#{EG6N0;{ZkMZ|66Jeu}P2N)0nD{nw+2kXv4NQtveLTJP zq8xB*CfeD&C5mN)kXl^4Z4P?bvd6J>2%aY;7Z;Y^%^s1COcy6RISiB8O=1X*RSx0i z?4}wxXqs&73|pz8<9*uS7g#mPX20_4GZqpdnl>m(;*1X-$>OpyqLNDt!RgaVs*FjF zpEdmoBj7RsbXIlQ0&Fe$z?xT6@xUxtvMKb%c*>wkiL%Dj^2jlvA92ce&*EpInxGm; zhH7{Ec1Y!xC@645q356GIhKr&|SNuCtFCPPwT=qG!zQi zf8Kc;@8L|h@U0+?F9Q@wk3E%0Zb+P*6XKSd7*&vP`D)qZRWD5=J|3oAVIByluY?Qn zaTzl&XEuTzt=Ce4lfd5&wESsarOEY)V?`&_KC2l(j%u31)GCOuH1;Ey!5W?7Vj_Xt zS4OE7xrP9fv%yJ3Omk^Q3YCS6uXsHEbPwgAMwF8pEvx8E&(v)9uug;#Cz* ztJ87q7^qM(UJtO5ooD%#E%^OpbQdPeZPr+K$FX7iabuC5c347c59={zQ8Pe>{&Yu9TjzTlu^n?A=u%gzT@W990w?gVu|$m zb(Z2_!!&KO#AqU)nWBhA?z58zl?8E1*)_fFj&LVdsmEoS{}-HEK0Sk_U8K$ROo zS&f4BHBE!>^LlF4XqTaHz5IW!xN}gFbBh|v4AZXIDft4ciGxcpM@8SE_G z55YtvyO15!XNCpN<_;C{#Iq8G4&@}mG`yT6VdunGQVCu)$`v)bsaLu+KjYuUhhBT!VVve9>y&;aOKnTM6I zQAh0so87UGBP+SH2bI&$iytVb?!=+KN91was;F!4qK=`f&F&b|Hhs3ne?s#TT5~1M*dJ28dCgdFYel( zZrY0cdX2WeD8+cJyDiA^@2KQf7isPl5`DoEApxq~m(&%E7-A{hS&M$ot5)&h%5sxZH zFG{L0Hlk06rNVQwz+^BQ1Q&~W)Hss4(u%aMV(LR}xhAKSFIId1iF`r3W8z!MhgYDF zWX+MoA7WuOqlsBFj1^Ume5Y=-W5z#SmX)!~?wh{-G5-k5Pvg3GcD5w=P(j&|5Cxt3 zQ=7lm{+K-Rt*>7Q5%U_Gm9~E}Fd-}C6b|$H@jw~YN^vqUg?=a3^py$hBeAEZ3uR&5 z`iBIz?dc4?iGG0`(ytbziHhj+!#bJ1$lG`d{#)>B{y1jDz&^?6H038ESfDq=J0KJYh!xV`Y3P4+H&(E5bF*=@`lpJ1hDHCAgk~pQD$NPw z40kv8^2$=JVqga4V>Y}DMt_xO#v^^U4R(PdzjQozP+vKn^`sb*-uc*tmvR5nb%lHt z$12E>4JsB4l)I>2ntpuI|GX0~T@nj{(&vp`**INl?1pR{9K^<_c2#B)c9v)6to|Y- zTFI$w&7rhj$By0lmKV3mUzWbww+8#{n8)PRf*w)6ak{9#QSm!rSWJ$dqteIpZAf|Z zm=B5J3{HqdOV@gWVQP};g!q>+g6;U}ONqB5U-0&~L$6bVT)o(`%vb}XTm3Y-3LClW z#FuYZR))(W#^V=~Oe@=J-K%gu)ELps>PquO2$5v{@z^P{hJQ$FQ zA$l?64|d3fqh1D<`F~*mByhB0BB0Oo`EEMEe{eYQfkE!AnGki+tav2&1Uy+^%* zG}A7CItGc{VK9gMhRBrXA1140{mLRP3w$R_@CuHf%Y4HzsSAKR7WxaR_N#TtT%Rs3 z_-|d@e{|dX-w^dOakcpOx4kg6V?}fojCaP>hGOlpFA?yuc?|2y!eeAbXzX7aQLHKM zkz2D{8Nk`*4yG_jCDAsAiEXvf6#PMm$Gl4*E#!EU*7mb5{jEB?KVF|8jp5`Fa*>gj z=7<-_mOR6%D%{F7Rd>rZ>&gM628E_nl~Ih+jA1k_u z=u5{EPfMh2N)mLdwXvG-D^0$0FcOkVX;m0nrz7j<$Z+akz(G17T$c=`(evW)Hh!Q$ zarlMhAuTZhC)nIuRsn3hF5u4@e8`=~%YgQgE8baxjkSO~0~ApA=b2bNgeufaB5^Me zxIU4mtw;7wgmo+-YPd1AwisWQJE?j;|F}|l$22wkYWA}m|2u&YG#%H1Nb`yCRdX-Q z0^g-5rq~HhtKuB8_-9sa?US06o*q6n^q?4!PxO zu}BX6CH;PRi=sVfoqm_Y5asKUxNsbcqfXGgEf&o9)8~}2N-VF?167OQ2ok&=^k}2F zGuwwF+n-g1m*lilI2*MHnW9%|;~mv)d{k=h zp)ERiuitw}7QgW$4I}CqDS~O_p_wBO5h68aVzYH4HCx#Mh^N z22nRj9@@2gd;n|78D!eTtXQr_@BcQ;F3i$A_gksSU;rpphqy>tbuX{`@sHI1&hY0> z_vewJgZw*k=l)L&(*M^RDJ#fQ*2MWE-69f3CgR(HCNthHdih1BKh!BMRJL|>HxDzag_13XftbrL zFx*$+GE+!4g=`B;L9`s=Ze`uGbex#B2S+_z#g?Bx5e~Of>WecNxqnz}X^|UFSUxb& z$d(`Ti^wnj2sS&TK_J|B3mhxuZmi}$6;bG^o=(Z>)ck?G2D-)uKLog#lr;S z$`;Ds*e5gAmtAHxs_t;uSO@AmP0cLyOrJA$h)6X~P4FzVAt; z*c%;$MBvgjG~`{2tGzq*S1{zontJgj_F9pC`Q(f{pU@~1g!EDUMT|yoH}+ni#RZiH*5Cb7gc>ThK55;4FizECc1M}Y7 zIO2uDEXmcvPInW3nGZ`8pi{@7XKkDifh1TTv?--O{*mwEMQP9a3Yh_Yw{rQQTfOOP6oi8_C?I((#u)D zzCZp>l3~Qhx>fTjB40m!GE<@)dcH|jK-n4fH-c(Q5lKuKq<&99@We75bCH;nes8V% z&nYvMxdwK|4~YQZd^!qg6jxlRn#$VgTK;g|`^I2Q{qZf@YPRI}7$G9aG^pwL??VsvTkc{0 zqwO=^zs}{&g2mFZ`FOrrD=FijMlju^$+Zlqc^dGb>eJD}p4fl=OLGcPN^B1=PTY3)_ zim|pf{}oa7CeFgkAd#VjOi=Sg&F~KY7Xp{eK~r%)(WmpbH38QDglGOnk5v?uz&;tK z+#iPQ$>Xm6`YY5jI2vj+bWBbJ9t%;2hgWzb&_TwFltmIf2#pH;AN55S&lot^NCnDR)(w}I+N)Fq5zwHj< zRhHgfZr*OJd>^S-4BQ&hnIeW2@Ku31yy=g{A7NIMa=HpCQ^~`|$H`y%@^HYh3wuP| z`UxZFhcFtc3~7vAnnxU!8x(Q|k2dJ`sN9WfWjM2YqGvVpK&~+~^M5>{RZ>>o8%2tu z&E-%$v!m9-*FHi1wbRKjDWl&$xhCpwxkl(e*=Y?&yZ6z(==~h-wAFprs_&sJ5tp0rb{sw;v7C%E(eK7;od&0)DlN_~Xdm`-|Jy(7)Wqmk3 zXCr0Tv=_<%t)rK~{_BNeLdTbavc<{7{!>c2J#F>@(ZL^ux;i}lm+bbLV9=t^1G3*_ zeY*I$Y68!}&7>W?5r2L^Ol82q60or?*#j`JuQxSlOuMw$sWWJG?95`jK3BD0`Vz0- z`<7j?H=$k$Q=nRT&tqp%qg1GA!-ZqQLE^;b11&}l>?j~Bg>evf8%g8S-Tt-;5Q!e zq?RVbekTKnK%DO^+4+egr{1o@!TpdSjUyBDPjQ6rH>NhN+MW;f@3(6b21q6p@!^xl zO$B$zdn+astC3}b&xkB(; zeuI6w|9XMzOJVUKc=Rg$k`y@%Md!n^l$^fxCim(6yFDIX(i6yN%%-LcoPgg0&iRqy z#EQzkO9M|ct(EMUNby3__P=2;;2}vLkpBX=0fxG%)hDo9{?=jqeWm`N{Pi!|8K9x( zg|30|jwF-L4v|lT9U?Ic^QE&$1+J-KO_W;ICP@~aLpi#Xt#lMPD*q!LsL2TT4DF9j z6tF$m*zuKSO!w#)li(ltS3=##boOGcHchI-vp-YKkM9qHe($fB&1oR9+jIcv$4jG& zZuHE9lS<~sWnua3NRMIl3jG!OiDB&!~G>u-3t;Kx8_1xTR_8HrW!30g~OR0M@Ht4>i|X~psU;366z&XCQ64|PT^JwiqOMb#j;qn4m&QcY{aY)&hYvf zD|U3I8KT>p9I;^AqYlZuWD7gUa9d2Y-Lxij<}%pa?%5Dll|D1$9Lp<8-fBQCe0zv8 zun$=OO-#fN#Se%k3d5H<6B>Z`h(ZatK5~!;m5*F=P6x<`L?8$6v#QkOLM$TFCT(zCtaEF z=)g!t{K*P}r#+1ejMSAQN;oPq=~qi!dFW9!?6iC)mjJe=D!;sH=Ho80M)|bU5;qxo z<}(9A^-glAVl^neEeF~sr4T>_59 z#VlnluCJCE8>M61cRdZ0a#M5}$RG&QjV< z`cWhti_T3O>E*qw4KZ`L;ifnuw2Gap%keWP1eF9bw@YkVRjO@fd?^dAK^W~5+LpSp zLvZ?-HDa}B+35^dq3}CZr_z|oXe4nN2MX6BU4L!srpV1{M=)x!kHyGFsyV^wX%*5& zj%l1whJO)b&x!UE3iDP1;c26;UTDejL&INB+KhA2c_u_ABi|LOpJ`R^s|a$BJz+9H zdDPgMs*=8il|hd?aR=5yE@2g9{K&VQhmkHU@JJ%kWWHa~N!4RqD2++I^CfXu_5W7= zWTk3tCGMpkrLQ||A!_pX76azn6v}?&m zYQ-f_=UbI0KX#;!e4*5&u6Y4`Ry>`fcNUITi@8o*gJa5aeV6c`HvMty;0pyA@p^8!VNIkd?eT4z8 zrD%+4j?eWy&|Bxv6a&-d;v%xldoCgfYRqjCk}?BvH?AH8b!P@j57Qd_R#(Bdr(VhW zm};qlY*799f_mR`Nj|{5-5~v7R{8DiT7CkW-;i_3@L~m|c4&A{x14}s7ra02XJ)?C zzpdZjBwyb3HeFshSS|Gyg1=iW6JIY~ZJ0$w2>CYv-iNub*?5bu%@L2Ktlw9LbLijh z!O~yh5%x>T-bJ7KCH&RJJX!g_4{SK)86cCLHl8<* zbAOBC0Z4HJWh(d(g0{3%`xHHwt_Js#ii6sMiUyjtK?SBo|6nUbGvqJHrA0X-SUVYs z@-@*|t2%>gi_-aT0C*cn9>b+Qz3upmK1k{Ul=|MbXwGhz13tk2;#plrA_n+RONs#d zcZTJE;MsqWtNH)clJ+k=o1$T$g>QipXo#i_^DVVO*;-<@;U)pubXTFAK}q;>bQD>?zpq^&Ue|EEJ0M5QW-U|Vogtf zO!qm+e!IUU4uoKiE=4dB@Z%JEplBeo;%uo79TH1lP-ahNarMzia##SG@rZ4kbF~gx ze4mT6tH&I#yq*APjOgTFYv}y)W>20Ta&;9fi6YR#478BPB{OoXBP2E+n z3(*@bSTGA4mBAV_5E}6`Uv787C%(r+3VmTdF1&t@&mTSi z;O^Ba&{KmCbHkq{yCc=WL?r6AC3F2K5)y%d=IIMAEbX-Q_;TH2CW2DS=me;2R8_K#I;Xzzq@cu=| zh0fety|9RjuDp6b2*aRGT{cAr4Z-Tg0ZTop6hT1ZbTv*v#et}ST-Q*Fn{EV)yo4G( zV+g3hbC`0g0B-`H%Tf5%K^I2t&NNRPp0m@>`nLc|h#b5=h#aOX-I_bSbs=ctk;gt| z%p!@6gXs*CNlf4iqMpeVKMsV5*`e4wz%7j61` zci7{#@grsvJeVFnKUzsTcy)y1VP);2Ge|QtcOl%o2 z`;5@w*}C%tJN!cJb&HH*`H{-@JMbM#z^#Q;aNK0=LFveZeaa}Pf{@xWg+!__ClNLG zf4!uz_f%ieC>cz?La+)-i~kh={c$GpMkXp5+OrIU zDD@HaU132fuzGTT&236x&96I1unQ#1GYeAuK9Qo)CS z$Ao}+Qtk_mhWvN6a)O|-*VZvn3$I~y>cxhnNc7o(?bGPnuh~8#dVa-^TtZW!z=2?y zVl|HKM&2sV;XsKuVYv6YhCc$9DD!in30aDM8rNFbJE|o|NculXXE(U8R=+Z&tz%y*@vxc;%=?( zYT{|(>SkguW^G|+XW{xUn-!z6?)K?0KGw089ooX`{pG?asYBTv#Ho{S@==5fZA8H4 zjT_e-9g~VP*Dbu}R8cXyuadOF1+RxHcI0V2CH>v!u{Ztaao^#r*mK%#tjGAOO};}R-9`trXm}wK zb+kLrNB7I)NF%jg98;g>lgynW3wQwI5>v69Akzw&15f@Hp<}6(Op4%S|BX(r9Ezir zIZ~fiZFK${8u6h`CSUR0&jh(X1k6g3dHX&;qtc)*)Pj3< zyq;oDt&a&%KK;eVrInUIjUXB4v`)m-o?^Nos`Lm^WaNz*r=gFvzpWVUOAQh~LuXU` zQaoG;5nj5Eqpqg9NLD@r3els_(KG88TuXNT5G%b}LcSxKt}A;-RfR<=)^tkSJkoCl ztdfZ)gQVkiedHerQ$C0^?t?JRnet*>AHFkyspjqago(grubp*JS8jp5zvTjm zIm}`i&UQxljc1l)765=_10+O%uia8}i%FTnT22Pf%t@&v5?S~l3^=Pi59LZbNMR)?l z+zWl7Sk%pZPe|u-vLQ`3eaOP`-R1BxwTL$Hl0>L!;WjL-DHn4d43wU>ivV6gynYS+ z%wMrjscs64!w&|wXFR$FzK(UVuHD}r^{$8nNd|zEt>}Hzz`($Uc`RKfnZ~%Qx}si% zG2cGds0;DD9km@-02b#tylF7c;&kUPe{sfr4W4mS@P)C6D{@$d^?l_dt5B1qH7|F& z0ycnVqQBx!jr5BAM;eu#wm_KBg~_CYwM>8kVrI#ep6aH4|02z6@_e&I8Z_H-PJ4KE zSpT`0S1s%BoQtyjUmuK`UJa#}TbA_!)M)Nls&mpywWaZyB1-w)w}RGUO1mQg1ZE>M zhjIwbbu<#qTDXA&?=Reg%3^_6j&COAfM2(q?T7L!aBt6l@Zi+AN#-g)(q}j0bMH3` z$Mw{fJ)7U{ZccIa=_ibffGm~RrKGmCwk_;2EMuK$G=RkZHhh~`5rJeYbL9Y(ltXyl zaA5tr6NLtac6Rw@WnRpMJD7)kWEUoZmUZP}H_J;Sf&wDL752lz&#C$@)nBOeqCIJM z&0%LYWY#8JGdFfaxUL-%lh2TJSMlz&Lo74aXbAPB5S0s5-Tn7|PqhH0yXi8qP18v( z;rWdN>c@j1(7!AOyq@tX!l%XH{QT30e_s<3`G2+X|7Bf!Co{XxG6>V>a~FFLHyh8- zu3vi#5i>IjH#Y?nM-!|6#=#b!0X2pQO2A|w0zDriU4dc81S}`Lo3J~inP^0ga3K!= z{cuNH1~mqC7LvnV82yg;tGGdC>c_3-}fd5xjqE+cR@3 z0@+W?15d68AKVT2&6CgvpLMa~37Bb+;AG5---?ictL zTRtwRS=$f|3IMD4kgqkMJ+biB#|e@9lzG z#r@ba+vb8^yumT6UEp7R{)h9_1}pW+{S;!lzm2c|l&7i}-xsNYy&%QH{YvGn{Oy zPq=&X+myyA+I zAv=N2kQwUZt4_Uopz<7#*hGUQdSPnff=_|Dov$dHy(4Z^4!0vs7+7-~L(V>+O2rb^ z5wL~3-;oH!G-IC;as^a0h3+E|troGdgwDC0fUeF)&vYWtNhTMRAYrzqu)BWgzX{05 z{|$|kQTTllTTVBYrKNyj7_6)xPKcrsp$9$}nFs>>N=wrOkkZ5v7_llvpJcqciy)?i z*+u3ul|zSHUM99<>`~WTyyz1QZ-KVRDviZ-6eXKg9pmY>To&pv1bhK^FdQ)#>G|BwJ!eTd{hJT2-=pKbnbv-;k#;R z7^r3)5}6rCDM2Kh!g**LU15ynMg2NO@YPt;p|X?$i70BJu9QKRRmvJ|g(eLD~UeHUOQ?CUCU1e`G4CE@Dk4rdOZ?r3fXIq91eUdt^e6;A5DU&_+sM781%z1TKeEq3d#`tAgwtW;Ya`{b)Vz~!WyS2Smz~}9MA3v zR~!WPkSc>L7=7%t5Pv6v9B-+dS0kyIf!{&jm3Vx>y4*8Eiuh0H(o|a*%p%&`L_Mee z+(mwJNTSL_q+Rd&%*o=>zeJEI+*{TXC$e}Qa`GY`;X%=+HoPdSdyC>*Mb!k+mHjAU zB4X*fYD{aBYHwU{dWP758w6({F&Q7JK`@X%N9!o7F}iA#@OD#(1j2GS!@qLXMX5Z2 zEKm^X@IQ56Ju%?(X0FX!5oq5)RmajNiB*TM2p2uk9((z{U+XZfDndv>kVCrfke`~z z`&)MGF|h26q%dYmaq~Ec;6k-X=QKy4`db)VM$HvQuHILAq2cD@BxL%D@ zdsGU8a$%Pk_c}o8lDnXCMqR#*|S% z!ngc|i-5-$=Sq_Aj8sRg0XNGjjFVLhh(nnEYYicDz?Sp0z0T~#j2f-Y5N;P?#puBc z?$~bt(?8Pnaaz`KJ80cy!o?~D5pN@3Y(>%zpt$XKZSVTqRK-Zh}rK2;J$;5~P*VP4G6sd>hdfOz(NvIg^jyz=U`Y z4ekZ`{@|oP)`DN8^<&MHi8rC5t!pmr{ylHMVW?INQtbIA#aUFIH{Ld*f~Mnh-A@JO zkc$+xpK1}kDKpLF4Y+7DVC%>tD0`1>0ghhAeG`#8xqI-7x%-+HVmwR2J_iVCEhPPu zZ}Fi()c_KiK!@)Ti@)NYsF=iF<-@cu-|a>OeJbyII@cc5=0_ADx(SnZ|#Le@Wmu$BP8{IY4WV=p^mVEy}%M2GSB{D&!Rf-@q-#@)w6@|CzBF9 zSiQKDKvcKSlsl394^`g_A|P|rcJjx85)=<*^;=!OX8pXszN7scIiq9OQvtc$NPvVE zlGqFbuf9sJ(yTEuV=Xr|xEDdXg0Q8a{4bZ4@>Q%2Is-s60I9CsUCQ!@4uNxSQ)w6+ zwRrVzsz=DtrQZ4Ko?z7F&sW;;*wlL_Ph8HLuUB3>;KM+M1OI6#XB2sl@cTg;Apa3* z{2KuM??o2=X-2WOGcYC=HZe7Dv3CCNxxAyDnd1-sl(ukkHnIJG0BE$5j@^PPx-V?_ z{)p3h603X}ex;a30xL{a6AX+sKZc^DdMPN1NCt4}Q~3UJJ(FLP_(ELCrb9iIZQClX zTXa)OghbyG>1FN(-bKVE52z>`1G5zG^{B_z%;?wUaAN7NX@m6LOt0r-oI|zxEwudr>`mae)!t`LISBexT=#i^sNJJXqZwsS0RQYC+o?$*J{}5 z`&}fcR?FB1fotb4vKJi(E2skE8e9s+H74LPu>M*1ouJF8PBD79far!}&!r2wKNlJ(do0baXaXkWGtz zRJRU3>!<*O!uK>F8Dl2d27Lt*Hn<8zXxpG4z1i{=g`fP^T)x;}(Fn{%Xbg3rk52u! zStmvziNkKp%9>{4C{pK~A#n?NhQg!28Z^k!m-2Uc$`4&m(392_Lb@I+G^zF!uxZ^a z;06rkBdDl~A18}h6G9D6rZQ}A$}0DOlh1BD5!cLXA$*A z2AD)X_5CLxGVIy{A|?we@&lNZi`qV57sU`+M(H!~v3u9lKObr+-tIcp7-sB0WKJ|M zL}cZ#+w_dl`Bv7aY|-JbdzYr3rs|{Ha^}2HU)hB1 zlt3Gy6OT+*V|C@OpcAx7N*9Y0jZ0v~2a#klF>mf>K0n z&t;4p@ZM9C?BQ*g#L;^=KNPGOnxNo%V6YC)uVUd>Pv30Ot6_(QPz`z*qk?ES?SJUY z`yvD#$(!~PFcomP7jMHL{OSQDK;p?43YKqz&GyfR>Rl2x-y<$q~x>h6)0f7x)k)~|ABym(65 zt_S@XE)r4}ufCWksFaUiLlE-h+EkFZt1kfDxbdvUW3d!m%84P15q`?o{)ri{3NdH)wI z8?jYqaB#Z?;u4t@;sD-oJ$?B;v}U(xzeGq(oxwa5_kt7plPwn4295C%kc947KgC1E ztV2eAsRoxqJ&x4ud5($!zHBX8!%#E2;)tvoSGV1&QXKJ{Alj#JzcWYCasZt%v_**e zj!ghd78DbMeGb7EKNbj(FjPhGnmXR|*1VAcD2zj^Fea!&d7!9WnCEg-%DR!To!HPx>;TW-g7U1c1I1VI_f5@N5<5Si7UsSP0uP02gm*qy+I**O}9grWeucd^DpTstbRXRG>bZ<7<%*_*z`=?=(}WV z?kch13Hn3FEvTG75{&0d09ztoOhBkv$?*1%KSsnkLm>E1$VvXkkR$ldA@^?ps8}89 zx3Zbb7wqIkHy4)y-XIYWF$U`p^?Q^6AO?6miNR_eb`d#v(=l5(cGgmV_0V|JbqgO-DZETztMUT#I4z~ID7DpV3Vh6KD2%g4YMMvp?XA$&hs{-L&uf{7r7^Q z(&T1DQ15_{!exd^DiagYjwfsm%TXv$OW;a0_GK=oY2?MC1|p)CL992_mGxsnH;RfN&$K%VgeV>M9G4N$L<=r0YVe}eI~xhpgy8Cc zOwAes{p*g;q&Y1vTSJhUEmJ~nscIlvC{R_18mxM#r7rF5hMI`X^yDdljo(;6>d~3N z(M(*fny(6X*80)<4ig=p0@Q<30L(_!_{?5f{hSF6FkRC6Yv}<_j(sKi(T!I`5opc& zI=@;Ak~gL_8E2;(#{w?ZgQ;cey_Z+F7;}I*=Tt&rH%P&IiwdAOc(urCfY76`n5W1r zcVI1(>*wrp$=ecep$(C)ss=@cL3*Mhbu|sj#y!TSQ8$X;Tc+p1lUts2RRaCh=3AHt zY2S_EH#^+0T9jr5m-j7bHybKJ9q3$v_47i#JiL38Dc1xZJb|x>5;-2TWN7X+GGaC2 zu6Zw1rJl}7tOCLFYEXSQ@Py(2o?7FFk!)F$hlw(uUi{X-|0d#x9s*|5o#^F3^P>M9 z$c`+`gZZ$m28J-8nB5)H&!kCyz=_dLCJTFLGdhHDW+jDw_| zzT8z!+aJ0E90wG*`bTTe=w4T8HW zeVF!Q@(43&hmO`W&>cs&mMS(`t!ls=-tNJ*ckF)YJ+ei}%&#%@gF^r( zo(tQGSVkehy)x7<6aO#c_N^bL^!1GEWaB8;!=0Pr)DkBKdpdOkGz^ z7C~LDI9AbR#bd5n+jX7#9b&v7YeHjI7Y+dq(lt_(u2xmQ0-2X~qe*)HGuVwTx4;s~ zxa`sLZw=IkJmVIr$P3eIfPTv;f3k`8Eyps$T0}PGdC5a{c)Wp7pcfJEAPfqg6mia1 zeBKZ)lt+jDHGp-4;8=Fo{1apHztjEw-9dNl)*%DKtB}G(q`U~i2%~_|BYFrgP^pAySYkZ7C3y9Vp^NL~>jq;!-cE$St{(I({?Gpm)oH6q3}FBQ$( z;5$t287%sy&2PpYe;Wb3F?%_k-nPMXWB-!U| zsD}%yD0hhcr7^F0bxtak@K#NJ)}j`6phBT*P-+z$W^#Y0O+LSEl3koj z0Ap7-#_zRc7sGys6;yfRDvE4LBJAZwK-yPoL0pDpU+3#A4?|cx)2>)w( zA89QE{E1e9;UtyuciL)kC1s0@wJJ$;6{xO{?Hixw>t3c=G3{@C7P^PkbcCtT?|je? zz%Qk`lYUzuzP!O3^yV`=k2)==5x;#uz@L7&ModOVYb&#FBhni8xw)pp{G%r!Tvw;K z>>6Ntl1O*_^lK8R7^O*+3t@ThjKPwj>(DQYU*;c@v^eDI!G^x?E{h2SocpqIF)Qw#oP^5N3Yryx8Pa#q_9hZij1Dkm_EJxL5hQ;V>c+zG4*H8dYXq* zpEytZ?0u@8r!9(g=WE&6z%y@-Fq|2ksJTqt9yLyhAgo$->O!j7-%}Dp>ac31X7GXa zYI^d$W5`4s!?RUGvekTT^jNu6Cv`gOW;og18O^?}Be#aGczMeMF_g8x&_;cse{0wG1=-~tSRKgO=ct76udVWRm#_!U2&xljR94c8MlMpA52#~sSL#RZs1w~NiHE`fGJck1TU zE9!uCRj+G|H$!StbgaX6VC8c0xT*kPzVS7=(d8!u>xwogc>>K7JzLQBRyFoWvbEBq z2)&*9ngDFF>OCnAnhfbu?!CKS@i8R{wF6Kc5V-5pySN47SAQ*txc zD9&_)1!2p2$c%K$8T>keaJ1jr@MNJT^sK)b9vlBGV505^ENOc2W%ve8G&#uZY}N>Z z!f;HI&b__j%RBtaECh4x9ov}3a%Q!GlbST@Nn?-wO6Vrm6UFh?d}P33b+De!XM4P* zX_#x%(bH>TvdHHDhd9GX;rplBkblXoOB=QvWHlX_eiV4Jyt)|>xq=!g5`C}#kXpxN znKMe;Z?Y)4$R7Asw>!{b81!ktWSvxZ+?QT{Jn*%=RL$T!*s~n7+_LO&!4v9pOz#f+ zKC|mDfgRNxy{~d?|AJjr;toNS>Y%f-opf-4ct*xBGPwUoI`0O$U6=TJ=NaF!#2zRL zGjMpJ5A<2D(LcH`cGg563qfg7;j#;63;P8iKxswN0IC!73VuQ+a@>v#2&msZ+{*a2Zin%!j0lASc8Bzk+1)r zsDouA38LvlZBSXNbn%lmv=}+rq{7C34mX7?6f0a7e%P`s^amO!&mf8GNQHE~_OXhP%m$R3>J-gnfUT z!Jk)605keLTu(FSd}t}RV?54EfAbtQdCA!wjtx3c36(PB2{#W7nWF8kpd)rLjcR1{ zhHxRkSlZAJIWS-RyEcM9&?=a4cV!mV?3P6ArjWH#lW;I5U?l;GomOaSIf%QcJFF`7 zByCW8miaAP=7l~F^a1~Ms1}}pyNyf~Q&Ydy2c_+*R!bQWTo~RV3UaDhlw30>9Jw(P zV9#drjd|ss*5Vbw`U>7E+c%_U(RgjISNJ8X)Ph+zPt!tYna?gf7<{neajHG!*kOV$BWdc9pJcS|6^D3Tm^jYp+&rB(0ErnDqv7I-#1 z6^P|h^VwO(<1(-jd6RO9PCbrze_c`2^-G4AB6}@MtWkb;cK*6-(3VQRIr-9zAy6T7 z;^;>ZSAtkiqnk7J#$q4);DxG>SL5c&^#wCqE9RqYr;44c>$C1M#(4C0m}*7fAHQ*Z z+cw6qfCmhrng;Ja`g^yzd+NBHSx`yneXPMX#F{+yKHwV01EvJk%Xn6#zhCl8oHB|C zm}!ROZ-hR@C{u|!jMS>MR2n_ll)Id^$n?<|mfY&12MjCU!Jp;nuRI%g_;^YBgt?>WM;M}dvzhZ(qx!RahC{OL`6dOw2{)k*Xbgg= z^{=2vVfhRjw7-H@oi=EJ01KNf`AdTx`!?s zifxjbfFKyc|L*kx-N=LgxVut+=YD?QJQ9%P#0SE#< z9a!==`|p9NNf|~)Cbnt7A)IOzE3Hm$E2Yaysr4pW6^adrk_b&HAIWN;<+b58pT=sP zi-PKeob7I=F=Ph z`V3esaeWxQOQTnDAISDrrkGA{^CfFj*)o|4m~?w4_IY zBYVdh9jtO52)AIAq|dI?w^WCyy0!40#as@jVAy?t5R1BGbU+(7o*bec$QsKd&Jraz zXz-;mUx`9VB*9cljMid%PoKqZGnpLG4q$Q<8#oOrC~9$WItOl271jD3%y=}I7`sfF zA0R(z=2}N3tP!Q5v8Zjsr4Y_!Og)lroovfm1GMC!%Ct+oh&f0c21fCKpNkPn1Z zdU27d=Ruh+x^NZ!lVcTmKjTYUCZH|pZdVl>`ov|%uEmt)vnCA+RP)fjruM+AMzQH1 z$+kFr2r*;WEOnbRTNoxsU3OI296njLLfT7VsO53<0nUMkp0>5pp~if{6=ibhyW;ZO zT*)C7R*ag-hSoho43QQ7GB3C=#5HwJ7d|fg1Q*L{)F`{562zwc&!BcAX-m4)$D+3ha}ldYQ6mT75%;fYvxLS$V@F@8wa~3wUBzL~0cnSZ&T2!DU$` z#T6QIa$qqqlS-(95z0H{86PvLc2a%G$>gdyx|;H0(8jpYbA{{l~;m@Vcf0RKD0l}ku9DGL>RgrM}L;;zxvSXhkQNw&As`7h0 z<>XMk#l5jYmZsD7qCEx)xqW1jw+)1r?J)1&=RLX3jz%#VD(p>J20YxhyHM*MxUFat{F?n!b2wjwro& zwrpXxSRH!PG$aMt*4NzV4eu2NFuO2yre_iFjuQToRXOI1zs2+&XY}mmqSbMc z$Ohs&PAy9~=%Lviq61-bc|k!75w4($ceuVgTMlS{V|%Io{e^R^Lz6;w`J_bsiwtFn zIL(NI_v!g1P<|B;LTZbvCy38MbfS)`Y6IB}Vq47zvg^R<7VSf2B=?q7sj>%OA#v`w zB19AHYnl>fRPgL8dh>#*s!NsZE4($RW(&1vgEO7;n}&aD_f;&C#YBN(iJf4N?tnN`;ixQ3$pv)KE1W;Bp)j;xP6>74 z!k}E8R3m97A#Vgnf<0v=?}=-S!t-&EIeO-q(;T!i>YSOZv?@+EBODFHk5{|K2vVq@KUoxkjo+`?w64uX86w$(`&t>9iy&^Kf=R_*uy{C#Ne)J=fQdcjW8YGvg!NQeMcPc!#WiaTEUrwo zgfsrgJUOr{u?T6`bdmgFU=R+wDX4iV6>~e*EZz9w(NMNe046i%(Nl zT|1kPM|!>>J{F5XQ=*KPp2!-LghIT^tXiavpit!qd&}Sf&4@lIcz=4523GX480thY zeY%u%`fh2TRmOE0ygXyIR$V|QJ}hxLL+V`gy&ZIJh6Vi-`lfNBPGE-kEI$B(stk;^o_0_`? z47NlRk{KQp2}>XBfwBwTU%=Ho1TW+dZ9c5%7qh3^^_`ccdvh5IpRl0VaI#wZf#^F`h)cl_Pnuy zw!6M*BvxmV<_Ne4(Up)LOKF&=gIRurnz4zz5zPU?>qE`czOrT)Ie2Igz2Uq9W#srG zM;&>?Z8-2Q!C)yG!A&G{rN$jX#`hHg9_C$+GQrS)oVjkxjMxr|kh|{w(ASd|0u4{T zJEQruq|JGNXQ=%uPaho)D+wa*=&XZ6E1;a~#=hX5xL(-vY6&W=AZ=iE$bRSc$xpi~ zq{HN}Q*^4eW;uLIkba&`m8uV?=PD5H6qGdi`jjI<3t5Lm#4No{4z>*^WPO8CSX(TbuLe$U_YKEAG>?g)fsZ*B zbpnlL@NACLkVQ;3Z6TM>B^5$edib_k;C8g!$O!MWN)j(3qJCp}qr^!##m6yCJ_*|W z)+6PxIKy;1%e=Kbb`>{$|4yiT7k-o-Jz?St&SXKIv@s+~n!-SPbZWLt9F8l%K%5k=pV`YFF zb4Z0~CF=o)$>aOyWE;THs^uo)*ci!Ndmy9fy&9&bF z>XDhYhA7r_4z9fclE0abEorHGvvekJ9G~OiVXqHZ1QRhIwLb#pOx5>n>Du{o51G9b zT~>Ux7(V+}cWqN7|FxhZ6p8 zECYb`dV+;#(IjQ+m5J_JjD>*+*tgI+EF(junl46;B#T}tGUKFK@h$!gI1-I8r?7P0(Ze|D0d@)4v8R62%3)G1 zYE<0N$N&VId`=ofxx|Nx;6qD|!eT-KA{UJ8#50Mfid9>RN|_lEnM|jQGI{ll8t39D zR;i+1&`^=192xIXqkE?n;O86kIuU2@4a0DX1|F14#r^c;`Fe6DVwoFrJz$8vT$Ew^ z8@~qj*Gm-Nmb>k;O7T%>oI}o|Q+4E<^D@V!6LV~Sn@#v>9>ARGk3f4D*g}d~)zU(a z=*LIj`cuAusUWe?dJ_658~<#VPHnVOgjolPQmfRM8d)4m%uO*i@US)JC+y(h*sK_Q zPX@T;vJsFv^^)-G$-3wps!F@$pkaInv?%(#*Dc^EepBuG;aFTz{V?4L=fDj3z^Om) zm9HA8dO6Yr8drwCp*D3bv=cs2-+EV)ZHk<43vH64%}23*=wLr$!j@8pz)N~tN>6MT z|KCe4QlV2WR-rNL+Ag6EUapU@x;XHa*{jK19*2#FBs*`|Kpj^cQFPF|{s!F0H>)AH z_I+O9dP@$((Y3`6&ggO=-XglKG|^@;I~)NsS1ot|X43$kTROx!y<+YO67R$~eAs#K zn+fMCgXB!1x0w5oi@7i{*vF50Cohk`Hlj%DDfHZ=s@~X~Kc>`}+3)bLzXwb7};aNC44e@(+hDyo4?~r zhDg8`l^>L)-l8ua>$h*hW|zR}r{Isw6mG~on})qdfGF1kxDVm z4rRIr9xj(#|1#0gkP(%|cDjGF=qV^Dj&Tgtnzy*>CWGyW9LH(d6kKr(C8(Y;m~}Z= zU|BIR4sqb}mNu|IfM{8g@wkB83nX)unbrU`Oy-}n=yB(rutBo+2ytMa%_$M7;c}_q z7G~jGYJ825wld0gA75M36@}redOsKg;M}&Vm+9^Lm!@m_%dF13{L*t*@F>!-6RvU7b$_ZtDU8BN5!0vVY7H2vmuSTpvF|C>Qa;OF;(MaMn7kL@7+ zCX)I6GHyXX^QPW)%U>SRSb*3tZ*wnfMg73$p2DaeRW4z<2;s5tN%2r;ELpt%?zqVZ zvk&aJo4BBTMBQIo{fZDSP&4J20_ozHL|JFpAO;xHzt!%x5vr(uJseZdf*+6Xjr|y* z+l6qi_Pi4PHkAV;Zm0*SGpCdllLvf1VV#tz=cY5$7%(sjwCITx?cdZ&qf-{t60MUs z5kfsQ1O&7JKxNQ}pzDsrblMlh+;vPUH1ANTvrNh|DQH5U5iRBj^Q>Xm0Jysz?ppeV zm16Iq1H-kq#qZ8Y{yqRWpIdZyONx$uhgQ}i8EO?%Ivm!f4xDKeia#pvp z945KWm}S6)XSPNwnoJXJx$e8TQX^7*>AARSYvgOfAey-phdo)CeaOx#EeB@lR$s}B zw1VHDoY8YyEwkWsQP0_3CjhLGSKSh&dw|Zw-4%lKSqz{|KR)5#!t4{f?T2p86*B)u zX6+_a(@JZBb)eB*{e{jMa=O$v4Fi2^lxqm z^2TVVv(RH+pg9?=22$L3XPOX2Ixtp2gQ7Q-kdvDv2IkF73dUw?wK^1Cvq z)5&S}ft3@wvVBF;X~pt>#S&`OGWD)=_ypsn)dck0C4d};iibaT7U=sQ0?{@6_%#mk z*RL1S|3r8Z{?CM$#D6Oz|BdKkiR!i;phpg_&4(Xa1UorM-|;U9nM6@6l{|!?eUbs+ z7)r^`1waY^do~MpBBX+@!ZV1`bANyT{s{AHh91ejTbs9`aifq|!N{1_g#b-)eN83G z#w4?;C4}&KD9GQ?y)W(z=v3+C4F4XGV^*+(v3jm}>C?#QgoLSb<#F}9=Om5jWGwUr z^G88Sr)Kra0YsqR)0qAD2T22SYv@(X7aAxAiXH!{Y*pi8ww@!t!QZ(JW3D*_>aeI{V$ZfgsqdafweX9{~iBn^!~RS zW)eRk3B-UL@~z(79MfE#8r`WdYq_qmXxP0Y3J)S8PwtFiHg92lB*<6|Rlh^jP96Y- z*B6R7qbwo}OMIN=eGEl>jaO^;%e8*|h8a_*7575x>Ph5e6|7~wxv~Wdq+XdjUO^p- zP)=SCilhy}d1b%k=qYHOEwFC|Os6KQY^`1o%3V9L+-Mugi>6kf%Jy^84NOwuhk7TV zV$pmw?VfAGbJB-?%{0%`aiC1rUy1sMiI@C2aCEeOcG|-nv1W1P**9S;cbHk|HU_S} z(EWrYDE)`I{88Ta6vHk(Gh)MLNNEyf_1pTTM#Y~31!0rfKn2Ihz5m; zxfwDls<}?SB@70nzC-7Nhk%1wl%q@3p_zVwDQU#yKSr7ZwA${PKZNe|ez22bDc;YLj; zB`Stk%1lL7rDwO+qyuzF9@SxGkvWTx#TZkKRVQG#G7d^r>`W8fQZ-v$Ot5wB@!gU2 z=d{NckJ$5ZYv%JKNA0yV5spt%_*(JWq~mm_|MGmh(;FXg(})x_H8e#mGzBbGERm?N z-klgpiZnwsA&_)L$#f&CNJbh~Y+h#0fT@El8%Kf;4n}0pf~n2h*=<4GxQT+)Oq<@k zMvJcv)Jo|b>OHjKmL2pdqnufGxbN+#dCF>;_UF9Gw6TTE$&!MjBlQhq%_Qkd+tus{ z=#i-upYlH^W$Biu(nU=1?i=`AN>h*V-SH{z#Pyvf>wS-=%tmpEV;FK*TQ((WsBS7N z+r?yT^zERD?})d?#&M@rl2BZ1N5}Q9%jU#P8!57xL_iK49v;OoYDu;IB_c;(=G>Vv zmrEN>B88@$n>Z4}%r;0?H_nwRy2b-E}q^$T-tmCa%os+He(WH<%*%s9f zwit7hUt5GG(e@rva!qC(!B;k7xfU|ME4!K!R6Wc=vYDn%sv@B)jP6bqX3^|U#Ygt* zMC3DA%lyY|Vxmk(I_xnQHagyF<|Q%KcZ%^@Jj&uT>X~B@2mqd^%PV_5IU{vBQ>T$A zr_CyOO{VkQ7?x6EY^90`-(&+dd75OA)lj7DqKf%77+H>rp{PRy_30VG@ott}vd?6& zf^MNs30L8yGI_G?18Ged4NqkTvQyVAR8X1~z!n0OKo)bNhrvW%Gwr8LH1YiV8G}Jp zJ5L&v$jMLSGj&TtSr-mx!%ye|3^OTdcx=f1=nj@eHXViGl#b8(^yj6)5XYqPGo+0P z>xJK}P z(~9iD8Nh&0ct_%;>0kn;iBnIc?nSAIuI*VDqQ>iM6$y3;S>VO)Y5bf=3$ zDi|MnfWml@lcs3$Wxom=3kyn!0xE)}t`M;er!Oa!t6qf{1(g;g0HILBMUhA{km8|n z-BCna*nlv-?_2t-J=j`Uar)U*MiN7vl2|Zh$P66vR;mx@q>C<sLT!M1{%^%Bu#W2DLA?S-jnwoxuJ zob4B+rkFmn{xK{$C*sZ2AcoM;@!#5JUJRT zTtGvAK$AW@a}E50o5Uds1B&UWTp!-ylg7K91v@Q-=XZ6cPw>Zvk#=#7Nc7I)C75q- zwy#RLh}l(k)HCJ}_$;-TWzLb#)O~9*ZikyUv!BjGrL`4Aw|$=1n?F8?8{C_yo8>4@ zNb=XdKsLgfeG1N@}PGE!YqR(Y$`QOSjoebdU^ z$m+=>=3iO-YV(NkEZWd%ASYOmRl3Q#-IQ~vQ~U%bgZ>{w$@R|ENa)kXq6_&c-qhA9 zb!nmVB=DPr2on*a84r6w=42eeI>Yut?Jw~CT%pt!Fk$h)4HjW!h+zQ|by_{l=;E0r zs*?CA5|K^MBl^l={$d84LSu{Kz)!vc!7olOAm;L8b-evqKa7>r4& zG%Q4o`zVDJKI{673e738BaluHk0LfC{bn|Aqlm|0P+Gg-e{?(PZ20n8przC@oQ2yt zD;w=dq;OK&!0s;(gPwsP^-B4|@TV}inmHho4W=AM{u1)kP5Gv37`3>t223{s?fw;k zpx_x7-~DinD4|<~2M{}@jwC+08XqCvj8?xB9W;&yHgV{a;GZ-OcU10!^KSOZ?46vlp>-VSQIxf4wg>)zTl+(Dv)=b z`amUN*ozvhs%wPN=jVCi@&+?sKK*4<)U@cLlJXVhaZmGBtv>1}P@hc8eBwrb=)(^? zySknL?I-6K7d!KZ_JC6kd19z|KIL1D-`YpDQt0){dW-hF6wB2lNXw$MzwDv=upYwk z&&1kf)5XSCd;bJ$OQ%LX5Iz>++0U7n_6chiDV1RLi~490O0H;UZ65@nY8posNO&^= z?G34BOb(HeRy)Wmu&Zzx@2xOX^1jZV9<+Z6%Wk<#`tT}KVRB-BB~QZ^J-X7-B}wbe z_-e0S>a8elp>i&^3A)(wFMzN(RA`ArD5$85Dqa1No-E_`h%B37U>>3!Ha3?Xe zAzfWwpIcqcEWN;%Lazk)+sRhr;=m(qd>k_+bFQ;tF45&fO=Mue|$|GA@+-8qD#m@1TWKT)cn`^<;QcAI4;zFqc)OO#MjZR;= zas*|)Ri~%iDD>y&mhmYh>8Y2-ywjT`+2oq^E2-Bncly z^h)%aOre#rZ%q$}7HLw4?!mlbQpD8vEZLB~d#^KM=V$}jfgTl5SOvihf<|E*SaxK9MU0 zL~bDeRutdKkcElv;_jPk7lwXdmIC8UPsQry9x@;n-lEY!pjg|&j=gfYeUv_SC2sr` z@dhHCsjshWN44}0@Voza*O3PM&ja4)6oZ0+o#E5O8XDbeNdGsP8exL?RamroEmEo% z$FK$5b{_`VnBUT6>RMQ?PnUeGpy7Ic_d(dBeVGArEXq}?GB!>OF`_AVHOabx3$(8t zfOAd@@QqJw|FzI<+S>6R-~A1MN9v8ZVton&4OMLlRW_W&H;3OZEsUjde`D%U$zM4Z zxsyfeHU?@_nWI}>EW@-hE%NQ;eZ>5a7<^buOD+3j39)pBp}|{ z2y8fPQwkKabg74}cVs*t5MRiX0Pr!19~SWGbhKR`8pzGwao>ozRF>kVOo}pXFLlV7 zC=Ci4^<%AtE5^tzGyogXmcSx9#3}LPlF~&leK=v}CK`6)4opD{*TPk)zsa{1PE{qE zQ)DhA@Dtl#ax9wN%b9n20cA+;f%8PF3DwE;O_YfhlzU*9cqf$0uq|Jt$l1`B>ct8@ zD311YJtMvp0CztR?O@i&!Cx_j^$3Npfr-TM9S!D(;eKsTyca*k|?b6Y`1 zr1R+%zAi#ZeQp%-!NpukELW2_yOG1^&Q1?qOoRNA#NtuB<3xz#%S(4$1z6VZR) zWH~;#jg)ln^M}0{56X?W`=d(Pf$D{$ucbv=G-sz@6pFnbmpKCn#HC8I^F^;pUAQQ( z^@w=ypts-K$bu@Nr%Vd!%Z(1iJY9WCs(`QB-5*WY7X#h@?S*W`xbW9?#LT{uU9xhNul6+ z4ylUN zeg3Ll*yYjJNPhcjU4@Zm{2bmHKo+kdKq86!6eUUH5% z;tYcI8~OmpEjc}%f;WoWqi{3|%820I2bDG?U+y$v_VJ@juTV5dgK%QQ=iub1j<9@|6bV}1kv`DR6hW@AxGH_R&8g;`QqPVS zLy4eMh*4%ZI|X6_sc09qDkZ#41;k9f@t|DnE~UKCDB7;*ZrR4#nk{#@ta!b7RgvQo zh8SDLNv)*oOtpLXE(2-bS7YWHuq zChacV4;|i-b19-gDay+07AU*08=)rEoMz9UM4PU%zJrU9^ByS|BTJdJ#GR1H(RArR zT6Z5MZV|~+@bf{T8V>1z8cH6t78?+T!sdG<-7TOf5m7*4UlgQW#OfGRBTtRMQ5YXs z3ylbh1^c}M3glR!7r#ly#!Qfak1a6@gl@rnGQOjz(OFKxjd+w9EckGCm;M4WCwVI%HOZu9856^>RNR+x<{DN7fdn)! zyO+ItWnX?2pRbp|cTIji)_f=%8#EQukLaGCjUU_uNh-&tJTVt-RHDiN)jGa1#jA*( z6o6})GUli)4>6HV(;dc5&#fG~kv>?p(R|x(CkrzY1uL&*uS9rxDpVY5KjdA#Z^gB% z(kvnhNb)sWOo~iZTRuv5HMlKmq3-gjum^a&YWHve^G{QN&ElP;I{{YCJq0;U_QC1= zp|uhBeCp;?@?wIuh+O3w23K6CyapDjJMwk?PteYHrE%PW=} z{eu)JF+wa61|2r~wb7bRb=X-$H}nmWw+~V;pSTk#RRkdyM_^sBSq&geW~03{>?4oD zV||Xx!wy%#E2kj^ep-`nK3BoUZwxhduPSTcn%2$CfM|3B$i@i}&ZA0oZvqRd&nag$Row}Z3 znrnObbTaJ?E`+wb$PdL=#kINI_CDQM>`=djl<~c4k)`f38xUY&z9HIAp4l5&3&o`m zR++1w`y&3GltE>PcFkxn?J>!O`@s?YJyCuegyg6GLD3G0&I!L%p|CrWZPE-tC=+Ek}kmaSV;ypJmXA6&fBoMRmBs z+@f(Z^d8kPeE_Z%lmyAN_>XJcxpg1KYni5((`8JcwNp=J>|u!q>UQ=C zdR1Z>*Y-iEb}&WJN@u2h;c*=jb)&r8j>SHKtjW4fmXGdDQG*+p_0TAE9?#kXi*SFk20jj@E>g3@KP4`)kA4wz0u)PJZTO8T*qoYP>&Y|g^0SV{HEpHX7FC{g% zT%XDwcI=y>K6CD-B*CZdS8e*}nf^$Kos2XTVPW@S)6sn%&M@TRee^siXxFSZ;+ktC zl+G1#<3j7mIud$x)M%^or@aR^g!3=DU0=>bT6nR;9*JPOp6e`}K1kKGuCJ=7sBTU+ z@bBXU@y)Tc==nshwQkEE-&E-dJ9@YoR@8+ z4Ppp_4Q+W>)d0^dXlZaCF%yG5CV!zIzi(^i2sNs9BZ5b5G&q zzVyN_ViL%XoqLb<#5)3B>f$|>dY9zvprkyS)Z@`Wcu40evAPz72sLS7OR^VXX9v5P z#kr8xx&M{J>uBGLSVQE*OC%#@o^xyN8f&g5T|XE5YC*lcm0jV_1`i8(Z$BtQ`9Vij z0Z{~b;pg*VERejW(Iwzm#}>30H?$+zYhmia1{EZA0+DP@VX>E33mtjQ(ESwh4Uw1! zN)o4(qb%W2CHXa8{74lbg&owx1D4)(Gjr(MRO~|H!#sHHH-Nm)TcR_Z;&Up8f@Fcb zZ%8Kwann~yiBV43gJK4ko^x?&A|rsVP`uzDWKbn>uDCsEs=Z%Ck~DfHVUMH_OPy!E zP`X@sjcq<*oy{q(o6dlwj6UN|7T29L$F$gKP&Z*VKCkU~R;+qC1V4lBFe9V_x~f9N z4hlEiq`VdGq$LRvUT%%;UUNOUCwVe<81I9_LbgEv!9Qf*huZv7ygTNSVd#?Kp1(5y zl2neJowGcPe0w^?NkGublY1|9g~HOvUC_H0Q^8;Q=>7Ko5o26*pWHlSr^)7Wz05wt zt)Tc!s@Nn<q~^B_&I^ha|s$Bh-xFc84FI%B26gnErYyn=OnpN=N#Lo{*weM|{z^XL||%xp)ebaU)aTI#SU)5l}# z=kKnLyi*=ozi3QWRgZ{562J1VlyEm*1fYkPtNIL7Fm$Yj+GdY{yh+~;zRAn|qD!Nc zLTd0v^=8l@L6wFvTIMjO?H-gaG?ZHaHYI&^3lJ^$ts9-4SYh)Tn0}ef6qA#O4gF9nK#?RAS%>siYM`ywu9K0< zp{Qd_bG7KueJ|(Zbx0|aO1YYn`l{!Sx?Ey*ogM5Ta_ zq*&hB!;efrVT!%ISp{pM$c*-`R4;Lo?(6%z@sSfRcI~@+sh|(ogV0rN2-PA4Z3_;) z1mPZ!gx`0TlN-&GP!{A_+8qzaT!)aAg_l?0BmA~~PK`cuQSB86=uaIsB9%!}A1Vc6 z=tsIWNbBGLoM*ebz1wZ5)et4n{b*eV8BGU|U^CvS^u}-1BaGChPbm~JeJOYvm6i#T zN(O3scr*g~P@} zTEwv4f+8}%8=tUdTS3H*Q4V#koD!P=t_u4x=*`0I=S+1X!LidJ>g%pTesoB?jZjV+ zflU!BZ^*n-=GhBM`U@V~MP?RBP64TD6e=)zgOd0@D+WZ_p(bt(mqqDule17G%F`f- zb|TT`iJs4Df~taED&%}J4`Np3pm7C^>PozA!-#y-PZBXCMvYp5k! zKHWZf+^AFKx<6an5E`>_g6k_?HNW)rFeWax5z=}7M*fV$2jzPFjB&F_YoxX-4eh1)VI(l=kBfZamb!D z{Wa&Pu{& zbW+viAKnvYT;UsVR5K3jp3@^#60~AVhY5VBINdL})kALb%rg_N8&n8uwpO;L&njf7 z&e-!+b-Z(i+eE7~75eR$9lUc-Eeq-0qzvOTJ)e=`QAw@R`cZqQFp&Mv0q%IAH`*)8Eko-E8Dr0N*pWStX<9FpSgfK(q z{ac^YCOnVO7qVBeeqi!Kke=f)@a28U>o5-7bIw`R?pB{N5?mw7gQxXK9GV!uZqT}M z7j14p#^)7@89SyY8AtW&GJQQ&dU}ob)Z38S((>}C->O+=Y+troydx-V%wsC3wekdh z79MsEubf7IbJ_Zc551zgJ21DA9(+w&4&!VTs^zb)_$<%dvD92DW*Wu5Xhi+3dXrPn zSCJ&q)|`D92dbQk`o{X&l1ActMq%SKo#UUqhaVxcmDrkx^b;fv=$_3S1(mAkw4pZ! z;I^F+3#>pO?82VP^B5lQ^kd1qbLS{pE%s}vFp*i22}OX2coR?C7Knyf+v#nVpawqvjRlipV)kI z0(D@LJff=Bky{`HhO!3Zg>*4NBb6hEQ^R#j>Z5LQncDkHtrhP=AonG2%e${rxx#|- zA{-j0Y>rIecNtc)$k%he)W609fQ>mhJs;QmJRPB04Uln@_fE5_y$)9d2`IjPd*#tr<|?ne$)N%$&l>S~tzOtVh)&24g%jg}sp# zDfInFUnO>Ufj;E-qkw@Tk{UePyIeCOk?<^&F1U%PA01^^ZVu1nMqY~hc2msPg7o zwXrN7z4+*mW4tPFgsu5r1@^_K;A|Qsru;4&{0yg78O?_;_T#j}7FrSZb3RRnM>s6@ zHJCLz>aSg{A3bP(iVdf$r^`ejuPYsLIucb6#HnmZ#o1z)PUyjyJys%mxE+EiDeW~G zH-^TGIXA6q*mLw@oG}$wE&5Dl99Kj+x2#2v=U_K$(ce3#o)Qp@wa;^H-!5AWvmf{PxKVreFV@K)SI(8a6vwm^j{b@Rl$D z82{%l!9t$pvP<q*| z(Z0^>eeoipH^0EI3NI^2)(xjJs?~8uR>BCed0Xfb7eBlDc$`m8U-U@PBY4{?Q}5VN ziK-Xu_GB$VNu^g3?@7mJ@}^i?x!BUQ$r$Eypk#>io<~9=a>_y1!OO0R7%~=k4ztUW zK3n|9W?|C+ZXFj;Zr3nD$r{&;?7WLj?fpQ{Av4vFh)!5eEKa4C+nQ4&?;SkKK74V` z$&Syd>JbHWW7JQD8FLZz!_S3eBv~*;?KNJsvJ4-kI=&em_;Pt zP<+^KA95qMQ(=>;oPxSWyJ3oWP)JcpL($fC)C^)?pQqG0oCCzCLvjn#}xs_wvo|g?Ntuige(3spM;T? z>0U{&Ie`R`<|t56T6AP5nA!@GCTgDXQYAN^v&?l+3ilgmx4^*%kJL7Eio)UyVu`^k zi3EzmFZl>q#k^h~;R?1o65uWg*sysjkRCGFw>$J!Y z)ljpmxzUHbi}hOyb(DJ9Ybf`*<1A>Aqo3K$lM&pp9=$^r;l9}*sGT-L*Zqkyr3MkO ztaP6N^lm5QVW+ZV6(&QxnTAhV+?fG+^vW8ls6}x9vOBYePh&^f$?f;@r^X^O-V-PKI^TSxDF+S350;cZmjXSiBZbUTNxPj zLq-E-cO`ub-_}(1#ytYl7bOHsE^h74Hk`FD7FCs_F z&?=bN_*&Zrn&pyF|aSub&v_bPom|X`{_O6NI$#(g$*n)+ak!HHQT<; zi~FTNhU)00jfj<7QHZMxOKKAc#lh#4n_oX!o*#j6rXx8wv6XIk! zDdZaBhw!_V&VPC2@9!F5VOxNyg_*O1!7r++brfxEhZHb>ey5$GPFC1WMfHHXfLAbkNnrnh z&=LWM(O%*z?H9@&*F#}3(xw{+Bk1yl?eL>twlsYEs~8?d2Rn5o23bNH550S*_evkR zAZb}zr=Fd$MZwNkZ(ACm{+u_?iw|4TlVma4=(MMinYxGy-K>-Fz^FuHX3r{WFK(a# zb`2M^&FPti%k_t7GMy~t$=2bi-UhP~jFMbS~1)dv_swc+;yR zaRvm%=}>iV=Kkbt>})^w6KJFZsG>3`w2wQkenXu4OW1iwgoc0dEyi%`m>xk^FVMdw zF4O$_g7|mTiMp8>Isc+Jsj7{&(m0+EYNb&}1Dqj0iU~(Z>?e`~^VCQ87%gbtYe;6} zZXj!XB)FWY`E#g zKgk=qkx8vUvoPte87D(cwkTh5oImAT_s%-XJoWBB--y@qfm+b5r{3SGaSXl8jEvr% zl+@@Gz08~RVt?41`ZoN z&&%Jay$UHWW21HjuX==mF0jYetv-`u<-b*Vt3r2C>sjaNktdHp3L9YY(bQzsCyxd$ z7pNkE#qdlmp@3GpRQXM!M-S0yM%-koji!$qoNDHX{B}XEF4G&-=&S^d=@{(PLiNXS zm1ITttkAfUQ*AJnLIE4D_Zjp=YUEzmNwmq(ri3NE*XSdF^Dlv^TK_oSGQvQuY&i(X zvz|ahbaG+>ji-x`vqH<8jkBV3mpwrgn5sU!1+R4!lB(v(28QrzEw*W*Kvxu&9^(g8 z3wzt358%6>+2crlVG|oI;VFr+I>X#UHN+slw~e18yeiczH=Q&h?IQv=1(=HK_OL?N z8esZRJ9%@pfxeF|`;7X7kG*@KNrx1Up^y_f(}%+QpJLwlF>A3GDGEyk7Z7A&tH&)9 zGZ@ta4edwh;O%0XiQp{8XS1*-R9!cX5u@-{TT4o`RPgC@)E-vL>zl<>Rar*Qw@c~8 zlFifF<>%&Tm0Q=>&;Fk^a@YpH(y%p!E@=vX;9c2clD0#e~7?+G%>`kVe5Pwmh0u0Z;-Y!^`Vr< zvzDku2#)8IHwW=R)P<%9u~=>5xo40meq5mS6X*|2&wE{*k+YN{o^UeqyqU&;P9Lyq z&A8=ro#o2P9QeA~z^p?L#{x9yf!ka;3wj~>F+*b9O0`)&>JhsHPNJ<~4dAhM!Xx%{ zrx)=vq*YH%G~>l1U$I-cKv6-nzR7`ABBPVONUS3hV3^`5#<4oo*Uh+87|yGB@gO&R z_`BL%Z_wN$X*d#BBiMNsb>qnjOIMx@_FrUSY|0ZiGBls!InsCvpl zSt{+RX0=hww9oZ)pPLV`y9<9{eAW@h9ki7?9h;k9y0|Umg*C5_&L9bT55FG}f-@7= z(3wnkJSn6rhj4Um(AW5W(Zuyc>%nX*YwcGVRn?i2GaG-D2#ioGlQe`OmRll!1o56Q zjN{G(19_3htCJGqo*)WE=WXFNaxrejuMKgZBgkKw*>iR7LFt;2^ropwR;9T{VW7~R zx>(M6GA*KDb#n%~$*Q=tWk$=WuO;w9^0BUPk34%dS#fC7TtqlVfMAA?6@p(q@0H!o zb_0@2aZb@N16?OePY+v7(WIx5FF)vV=d$cnAw-rF79_U6T{RzQ*Ii#{Yi(nmhZ-dVG3&twm6NnLT_ObNI8D z?$%vkPYeUw$NUjCY!W^Cp6|@yc}o_T-55qF>e)6(GFxD-=iHHvpIkS7VmRqrU?VY{ z<$G$<+KA`%bcZMnPAKN&(lDiGW5fK(PFnlmfKk_}y*8diwCu8*dBW22gQevhW(C62 zkGU+iy=cKm ziKR>`jUIR~ZLiCmiAznM<9Ig(2*lq%c55G+bMrv8?Ooa}GH9*RQ-LZ?B|i8xob#2M zy9@Vff2$vFji&NtZ*_Boa~&I?qrtG*{b1K+<(bv+mQ6G}ra=c0>Z7%WD{}d(2ohFk5GQjh zzG|CYxM@?dHVc%Y3IIpv^VG;b1nQo!)`Toj36NwO9`dz~uBTY{v(9Fkl{a)cc?MrL zG&|LOQud7KIzd%k(g7}bA}&&^2WQ2NF@Jz}RF81KID^_gunIaLlk^UXIz!oOwUyj) z^+LFYc3qAMTacfK6krSYC5w+K!}1m7y$P{71~+3rm-PqDl4Ffj7kXRkoE0c}Q6Fxd0JzFy#jzttYd5r2)Z**Zu6(rTZo z#JUzXt2$RM*)J-lT7#v>@uYnyC*q|CF&7pP)ewcxhAv%~I$edQrHQ7{yi#F-c7NS| zA@la$-Q7NgLAu}|gt>6_FXjUDJxnM}C`3dE914mI>O)l?@g@osR3kMM6guRJ`J>5- zDhn}6%84<{ND7O}DT%5m!$CprLhlxPKf1B(@77F&d|m^2VgBHT1=6JH|J=w5$w`Wd zDk(F`iv3qtM4@h}cb#R_Ag{ZgJl_l*4GPNE!Hn7LU+tTijjS!0zr*1QIhZ-yKzfct zkX0sjzgSEDe8^N|&?OF#RYW>u6>*2#{bmv9ztj08FY;&SQSWJQB_Yn!AdBNWyx}*~ z#Q2@_zX`wx_!o0!@ zhJR1zPjwtWOAZQ?S`9$r$qaI8pZy@YiuVVS(f|nI;jh8{HCzbE!^z|yG_9x05;!3f z8UUHlABRax_6M4>7LJY(p4nfjkVyFxNw99gQWHdy9@3lp$6*dr{DI`(TCN5TCVxIm zz3W43S4iMc+V&iDsHDgdkhpYZ&i>iy%7 z2?xtRtNz2>eb2i6(Rl{Pe>nfwO#KEE{_~gldqnFUF87@w`o4YS9bMs@6)F8z>K{V=`&NT@+g{&n#qgJY|DYA+&&U3K`@J874c+878h>wm z{Pz}yKkIy7^?cXN@0;nH{+Z4{OxE`W!*|8g-|U6^F9rTvDfQ1s{(UX$k7vQvYMdv{Mz{+(jsonOut.trim()) + assertTrue(report.success, "Pristine scaffold must have 100% valid validation report") + assertTrue(report.checks.isNotEmpty()) + assertEquals(0, report.failures.size) + assertEquals(report.totalChecks, report.checksPassed) + } + + @Test + fun `machine-readable failure contract on nonexistent directory`() { + val nonExistentPath = File(tempDir.toFile(), "nonexistent-dir-" + System.currentTimeMillis()).absolutePath + + val (stdout, stderr) = + captureStreams { + val ex = + assertFailsWith { + createBossCLI().parse( + listOf("plugin", "validate", nonExistentPath, "--json"), + ) + } + assertEquals(1, ex.statusCode, "Nonexistent target must exit with code 1") + } + + // Assert: stdout is valid parseable JSON containing success: false and structured check failures + val report = launchpadJson.decodeFromString(stdout.trim()) + assertFalse(report.success, "Report success must be false") + assertTrue(report.failures.isNotEmpty(), "Failures list must contain failure entries") + val targetExistsFailure = report.failures.firstOrNull { it.checkName == "target-exists" } + assertNotNull(targetExistsFailure, "target-exists failure must be reported") + + // Assert: stderr contains NO raw error text + assertEquals("", stderr.trim(), "stderr must contain NO raw error text in --json mode") + } + + @Test + fun `linking unbuilt project fails with clear compiled jar requirement`() { + val pluginDir = File(tempDir.toFile(), "unbuilt-plugin") + createBossCLI().parse( + listOf("plugin", "init", "unbuilt-plugin", "--dir", pluginDir.absolutePath), + ) + + val (stdout, stderr) = + captureStreams { + val ex = + assertFailsWith { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath, "--json"), + ) + } + assertEquals(1, ex.statusCode) + } + assertTrue( + stdout.contains("No compiled JAR found in target directory"), + "Must explain compiled JAR requirement", + ) + assertEquals("", stderr.trim(), "stderr must be clean in --json mode") + } + + @Test + fun `links plugin when BossConsole is offline and stages cleanly with version-rotation`() { + val pluginDir = File(tempDir.toFile(), "offline-link-plugin") + createBossCLI().parse( + listOf("plugin", "init", "offline-link-plugin", "--dir", pluginDir.absolutePath), + ) + + createSyntheticJar(pluginDir, "com.example.offline-link-plugin") + + val (stdout, _) = + captureStreams { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath, "--json"), + ) + } + + val jsonResult = Json.parseToJsonElement(stdout).jsonObject + assertEquals("staged", jsonResult["status"]?.jsonPrimitive?.content) + assertEquals("com.example.offline-link-plugin", jsonResult["pluginId"]?.jsonPrimitive?.content) + assertEquals(false, jsonResult["running"]?.jsonPrimitive?.boolean) + + val stagedPath = jsonResult["stagedPath"]?.jsonPrimitive?.content + assertNotNull(stagedPath) + val stagedFile = File(stagedPath) + assertTrue(stagedFile.exists(), "Staged JAR must exist on disk") + assertTrue(stagedFile.name == "com.example.offline-link-plugin.jar") + + // Verify version-rotation pattern: dev/plugins//v/.jar + val versionParent = stagedFile.parentFile + assertTrue(versionParent.name.startsWith("v"), "Parent folder must be timestamped rotation directory") + assertEquals("com.example.offline-link-plugin", versionParent.parentFile.name) + } + + @Test + fun `links plugin when BossConsole is online and live reload succeeds or fails gracefully`() { + val pluginDir = File(tempDir.toFile(), "live-link-plugin") + createBossCLI().parse( + listOf("plugin", "init", "live-link-plugin", "--dir", pluginDir.absolutePath), + ) + + createSyntheticJar(pluginDir, "com.example.live-link-plugin") + + // 1. Success case: host is online and reload succeeds + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock to simulate online BossConsole") + var reloadDispatchedFor: String? = null + SingleInstanceManager.pluginReloadHandlerOverride = { id -> + reloadDispatchedFor = id + true + } + + val (linkOut, _) = + captureStreams { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath, "--json"), + ) + } + + assertTrue(linkOut.contains("\"status\":\"linked_and_reloaded\"")) + assertTrue(linkOut.contains("\"running\":true")) + assertEquals("com.example.live-link-plugin", reloadDispatchedFor) + + // 2. Failure case: host reports reload error + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + error("ExceptionInInitializerError on host") + } + + val (failOut, _) = + captureStreams { + val ex = + assertFailsWith { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath, "--json"), + ) + } + assertEquals(1, ex.statusCode) + } + assertTrue(failOut.contains("\"status\":\"linked_reload_failed\"")) + assertTrue(failOut.contains("ExceptionInInitializerError on host")) + + // 3. Offline case: BossConsole is closed, link stages and exits cleanly with 0 + SingleInstanceManager.release() + SingleInstanceManager.pluginReloadHandlerOverride = null + + val (offlineJsonOut, _) = + captureStreams { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath, "--json"), + ) + } + assertTrue(offlineJsonOut.contains("\"status\":\"staged\"")) + assertTrue(offlineJsonOut.contains("\"running\":false")) + + val (offlineHumanOut, _) = + captureStreams { + createBossCLI().parse( + listOf("plugin", "link", pluginDir.absolutePath), + ) + } + assertTrue(offlineHumanOut.contains("BossConsole is offline")) + } + + @Test + fun `tests SingleInstanceManager reloadDevPlugin handshake protocol`() { + var reloadResultShouldSucceed = true + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + reloadResultShouldSucceed + } + + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + // Test success + val successRes = SingleInstanceManager.reloadDevPlugin("com.example.sample-tool") + assertIs(successRes) + + // Test failure with message + reloadResultShouldSucceed = false + val failRes = SingleInstanceManager.reloadDevPlugin("com.example.sample-tool") + assertIs(failRes) + assertTrue(failRes.reason.contains("Failed to reload plugin com.example.sample-tool")) + } + + @Test + fun `scaffolds eval-test-tool and verifies wrapper files and structure`() { + val targetDir = File(tempDir.toFile(), "eval-test-tool") + createBossCLI().parse( + listOf( + "plugin", + "init", + "eval-test-tool", + "--template", + "mcp-tool", + "--dir", + targetDir.absolutePath, + "--force", + ), + ) + + assertTrue(File(targetDir, "gradlew").exists(), "gradlew must exist") + assertTrue(File(targetDir, "gradlew.bat").exists(), "gradlew.bat must exist") + val wrapperProps = File(targetDir, "gradle/wrapper/gradle-wrapper.properties") + assertTrue(wrapperProps.exists(), "gradle-wrapper.properties must exist") + assertTrue(File(targetDir, "gradle/wrapper/gradle-wrapper.jar").exists(), "gradle-wrapper.jar must exist") + assertTrue(File(targetDir, "src/main/kotlin/com/example/evaltesttool/EvalTestToolPlugin.kt").exists()) + assertTrue(File(targetDir, "src/test/kotlin/com/example/evaltesttool/EvalTestToolPluginTest.kt").exists()) + assertTrue(File(targetDir, "src/main/resources/META-INF/boss-plugin/plugin.json").exists()) + } + + @Test + @EnabledIfSystemProperty(named = "boss.test.e2e.gradle", matches = "true") + fun `scaffolds eval-test-tool and compiles out-of-the-box with gradlew test`() { + val targetDir = File(tempDir.toFile(), "eval-test-tool-compile") + targetDir.deleteRecursively() + + try { + createBossCLI().parse( + listOf( + "plugin", + "init", + "eval-test-tool", + "--template", + "mcp-tool", + "--dir", + targetDir.absolutePath, + "--force", + ), + ) + + assertTrue(File(targetDir, "gradlew").exists(), "gradlew must exist") + assertTrue(File(targetDir, "gradlew.bat").exists(), "gradlew.bat must exist") + + val isWindows = System.getProperty("os.name").lowercase().contains("windows") + val gradlewCmd = + if (isWindows) { + File(targetDir, "gradlew.bat").absolutePath + } else { + "./gradlew" + } + + val pb = + ProcessBuilder( + gradlewCmd, + "test", + "--no-daemon", + "-Dorg.gradle.parallel=false", + "-Dorg.gradle.vfs.watch=false", + ).directory(targetDir) + .redirectErrorStream(true) + + val process = pb.start() + val output = process.inputStream.bufferedReader().readText() + val exitCode = process.waitFor() + + assertEquals(0, exitCode, "Scaffolded project ./gradlew test must exit with 0. Output:\n$output") + assertTrue(output.contains("BUILD SUCCESSFUL"), "Build must succeed") + } finally { + targetDir.deleteRecursively() + } + } + + private fun createSyntheticJar( + pluginDir: File, + pluginId: String, + ) { + val libsDir = File(pluginDir, "build/libs") + libsDir.mkdirs() + val jarFile = File(libsDir, "$pluginId-0.1.0.jar") + + val classEntryPath = EndToEndFixturePlugin::class.java.name.replace('.', '/') + ".class" + val realClassBytes = + EndToEndFixturePlugin::class.java.classLoader + .getResourceAsStream(classEntryPath)!! + .readBytes() + + val manifest = + PluginManifest( + pluginId = pluginId, + displayName = pluginId, + version = "0.1.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = EndToEndFixturePlugin::class.java.name, + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + + JarOutputStream(FileOutputStream(jarFile)).use { jos -> + val mEntry = JarEntry("META-INF/boss-plugin/plugin.json") + jos.putNextEntry(mEntry) + jos.write(manifestBytes) + jos.closeEntry() + + val cEntry = JarEntry(classEntryPath) + jos.putNextEntry(cEntry) + jos.write(realClassBytes) + jos.closeEntry() + } + } + + private fun captureStreams(block: () -> Unit): Pair { + val outBaos = ByteArrayOutputStream() + val errBaos = ByteArrayOutputStream() + val outPs = PrintStream(outBaos, true, "UTF-8") + val errPs = PrintStream(errBaos, true, "UTF-8") + val prevOut = System.out + val prevErr = System.err + try { + System.setOut(outPs) + System.setErr(errPs) + block() + } finally { + System.setOut(prevOut) + System.setErr(prevErr) + } + return Pair(outBaos.toString("UTF-8"), errBaos.toString("UTF-8")) + } +} + +class EndToEndFixturePlugin : ai.rever.boss.plugin.api.Plugin { + override val pluginId: String = "com.example.test-fixture" + override val displayName: String = "Test Fixture Plugin" + + override fun register(context: ai.rever.boss.plugin.api.PluginContext) { + // No-op for test fixture + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginScaffolderEvalTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginScaffolderEvalTest.kt new file mode 100644 index 0000000000..10a43feecf --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginScaffolderEvalTest.kt @@ -0,0 +1,263 @@ +package ai.rever.boss.cli.plugin + +import ai.rever.boss.plugin.launchpad.HostMeta +import ai.rever.boss.plugin.launchpad.PluginManifest +import ai.rever.boss.plugin.launchpad.PluginScaffolder +import ai.rever.boss.plugin.launchpad.launchpadJson +import ai.rever.boss.plugin.loader.PluginManifestReader +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PluginScaffolderEvalTest { + @TempDir + lateinit var tempDir: Path + + @Test + @Suppress("LongMethod") + fun `scaffolds across all 4 templates into isolated temporary folders`() { + val templates = listOf("mcp-tool", "ui-panel", "background-service", "full") + + for (tmpl in templates) { + val targetFolder = File(tempDir.toFile(), "plugin-$tmpl") + val result = + PluginScaffolder.scaffold( + name = "test-$tmpl", + templateName = tmpl, + targetDir = targetFolder, + force = false, + ) + + assertEquals("com.example.test-$tmpl", result.pluginId) + assertTrue(targetFolder.exists(), "Target folder should exist for $tmpl") + + // Verify canonical files exist + val manifestFile = File(targetFolder, "plugin.json") + val gradleFile = File(targetFolder, "build.gradle.kts") + val settingsFile = File(targetFolder, "settings.gradle.kts") + val gitignoreFile = File(targetFolder, ".gitignore") + val readmeFile = File(targetFolder, "README.md") + val wrapperPropertiesFile = + File( + targetFolder, + "gradle" + File.separator + "wrapper" + File.separator + "gradle-wrapper.properties", + ) + val wrapperJarFile = + File( + targetFolder, + "gradle" + File.separator + "wrapper" + File.separator + "gradle-wrapper.jar", + ) + val gradlewFile = File(targetFolder, "gradlew") + val gradlewBatFile = File(targetFolder, "gradlew.bat") + + assertTrue(manifestFile.exists(), "plugin.json missing for $tmpl") + assertTrue(gradleFile.exists(), "build.gradle.kts missing for $tmpl") + assertTrue(settingsFile.exists(), "settings.gradle.kts missing for $tmpl") + assertTrue(gitignoreFile.exists(), ".gitignore missing for $tmpl") + assertTrue(readmeFile.exists(), "README.md missing for $tmpl") + assertTrue(wrapperPropertiesFile.exists(), "gradle-wrapper.properties missing for $tmpl") + assertTrue(wrapperJarFile.exists(), "gradle-wrapper.jar missing for $tmpl") + assertTrue(gradlewFile.exists(), "gradlew missing for $tmpl") + assertTrue(gradlewBatFile.exists(), "gradlew.bat missing for $tmpl") + val isCrPresent = gradlewFile.readBytes().contains(13.toByte()) + assertFalse(isCrPresent, "gradlew must have strictly LF line endings without CR") + assertTrue(gradlewFile.canExecute(), "gradlew must have executable bit set") + + // Verify plugin.json content + val rawJson = manifestFile.readText() + assertTrue(rawJson.contains("\"requiredPermissions\""), "plugin.json must emit 'requiredPermissions' key") + assertFalse(rawJson.contains("\"permissions\" : null"), "plugin.json must not emit null legacy permissions") + val manifest = launchpadJson.decodeFromString(rawJson) + assertEquals("com.example.test-$tmpl", manifest.id) + assertEquals("0.1.0", manifest.version) + assertEquals(HostMeta.CURRENT_API_VERSION, manifest.minApiVersion) + assertEquals(emptyList(), manifest.requiredPermissions) + assertTrue(manifest.permissions.isEmpty(), "Templates emit emptyList() by default for open access") + when (tmpl) { + "mcp-tool" -> { + assertEquals(1, manifest.mcpTools.size) + assertTrue( + manifest.mcpTools + .first() + .name + .startsWith("mcp__com_example_test_mcp_tool__"), + ) + } + + "ui-panel" -> { + assertTrue(manifest.mcpTools.isEmpty()) + } + + "background-service" -> { + assertTrue(manifest.mcpTools.isEmpty()) + } + + "full" -> { + assertEquals(1, manifest.mcpTools.size) + } + } + + // Verify Gradle structure + val gradleContent = gradleFile.readText() + assertTrue(gradleContent.contains("kotlin(\"jvm\")")) + assertTrue(gradleContent.contains("ai.rever.boss:boss-plugin-api:${HostMeta.CURRENT_API_VERSION}")) + assertTrue(gradleContent.contains("https://github.com/risa-labs-inc/boss-plugin-api/releases/download")) + assertTrue(gradleContent.contains("artifact(\"[revision]/[artifact]-[revision].[ext]\")")) + + // Verify standard source sets (src/main/kotlin/ and src/test/kotlin/) + val entrypointClassPath = manifest.entrypointClass.replace('.', File.separatorChar) + ".kt" + val srcFile = + File( + targetFolder, + "src" + File.separator + "main" + File.separator + "kotlin" + File.separator + entrypointClassPath, + ) + val testClassName = entrypointClassPath.removeSuffix(".kt") + "Test.kt" + val testFile = + File( + targetFolder, + "src" + File.separator + "test" + File.separator + "kotlin" + File.separator + testClassName, + ) + val resourceManifest = + File( + targetFolder, + listOf( + "src", + "main", + "resources", + "META-INF", + "boss-plugin", + "plugin.json", + ).joinToString(File.separator), + ) + + assertTrue(srcFile.exists(), "Source file missing: ${srcFile.absolutePath}") + assertTrue(testFile.exists(), "Test file missing: ${testFile.absolutePath}") + assertTrue(resourceManifest.exists(), "Resource manifest missing: ${resourceManifest.absolutePath}") + } + } + + @Test + fun `asserts non-empty collisions fail fast without force`() { + val targetFolder = File(tempDir.toFile(), "collision-test") + targetFolder.mkdirs() + File(targetFolder, "plugin.json").writeText("{}") + val dummy = File(targetFolder, "existing.txt") + dummy.writeText("blocking") + + // Without force -> fails fast + val ex = + assertFailsWith { + PluginScaffolder.scaffold( + name = "collision-plugin", + templateName = "mcp-tool", + targetDir = targetFolder, + force = false, + ) + } + assertTrue(ex.message!!.contains("exists and is not empty")) + + // With force -> succeeds and overwrites + val result = + PluginScaffolder.scaffold( + name = "collision-plugin", + templateName = "mcp-tool", + targetDir = targetFolder, + force = true, + ) + assertEquals("com.example.collision-plugin", result.pluginId) + assertTrue(File(targetFolder, "plugin.json").exists()) + } + + @Test + fun `sanitizes numeric prefixes and reserved keywords in package and class names`() { + val testCases = + listOf( + Triple("3d-viewport", "p3dviewport", "Plugin3dViewportPlugin"), + Triple("123-audit", "p123audit", "Plugin123AuditPlugin"), + Triple("default", "default_pkg", "DefaultPlugin"), + Triple("while-loop", "whileloop", "WhileLoopPlugin"), + Triple("fun", "fun_pkg", "FunPlugin"), + ) + + for ((input, expectedPkgSuffix, expectedClass) in testCases) { + val pkg = PluginScaffolder.sanitizePackageIdentifier(input) + val cls = PluginScaffolder.sanitizeClassIdentifier(input) + assertEquals(expectedPkgSuffix, pkg, "Package suffix for $input") + assertEquals(expectedClass, cls, "Class name for $input") + + val targetFolder = File(tempDir.toFile(), "plugin-sanitize-$input") + val result = + PluginScaffolder.scaffold( + name = input, + templateName = "mcp-tool", + targetDir = targetFolder, + force = true, + ) + + val manifestFile = File(targetFolder, "plugin.json") + assertTrue(manifestFile.exists(), "Manifest should exist for $input") + val manifest = launchpadJson.decodeFromString(manifestFile.readText()) + assertEquals("com.example.$expectedPkgSuffix.$expectedClass", manifest.entrypointClass) + } + } + + @Test + fun `assertSafeToPurge protects git repos, user home, and arbitrary non-plugin directories`() { + val safeDir = File(tempDir.toFile(), "valid-plugin-dir") + safeDir.mkdirs() + File(safeDir, "plugin.json").writeText("{}") + // Should succeed without throwing + PluginScaffolder.assertSafeToPurge(safeDir) + + val gitRepoDir = File(tempDir.toFile(), "fake-git-repo") + gitRepoDir.mkdirs() + File(gitRepoDir, ".git").mkdirs() + File(gitRepoDir, "plugin.json").writeText("{}") + val gitEx = + assertFailsWith { + PluginScaffolder.assertSafeToPurge(gitRepoDir) + } + assertTrue(gitEx.message?.contains(".git") == true) + + val nonPluginDir = File(tempDir.toFile(), "random-docs-folder") + nonPluginDir.mkdirs() + File(nonPluginDir, "important.docx").writeText("data") + val nonPluginEx = + assertFailsWith { + PluginScaffolder.assertSafeToPurge(nonPluginDir) + } + assertTrue(nonPluginEx.message?.contains("Refusing to purge non-plugin directory") == true) + + val userHome = System.getProperty("user.home")?.let { File(it) } + if (userHome != null && userHome.exists()) { + val homeEx = + assertFailsWith { + PluginScaffolder.assertSafeToPurge(userHome) + } + assertTrue(homeEx.message?.contains("user home") == true) + } + } + + @Test + fun `scaffolded manifest passes host PluginManifestReader parse and validation across all templates`() { + val templates = listOf("mcp-tool", "ui-panel", "background-service", "full") + for (tmpl in templates) { + val targetFolder = File(tempDir.toFile(), "manifest-verify-$tmpl") + PluginScaffolder.scaffold( + name = "test-$tmpl", + templateName = tmpl, + targetDir = targetFolder, + ) + val manifestFile = File(targetFolder, "plugin.json") + assertTrue(manifestFile.exists(), "Manifest should exist for $tmpl") + val hostManifest = PluginManifestReader.parseManifest(manifestFile.readText()) + PluginManifestReader.validateManifest(hostManifest) + assertEquals("com.example.test-$tmpl", hostManifest.pluginId) + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginValidatorEvalTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginValidatorEvalTest.kt new file mode 100644 index 0000000000..879cfb1538 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/plugin/PluginValidatorEvalTest.kt @@ -0,0 +1,523 @@ +package ai.rever.boss.cli.plugin + +import ai.rever.boss.plugin.launchpad.DevPluginArtifacts +import ai.rever.boss.plugin.launchpad.HostMeta +import ai.rever.boss.plugin.launchpad.PluginManifest +import ai.rever.boss.plugin.launchpad.PluginValidator +import ai.rever.boss.plugin.launchpad.launchpadJson +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.io.FileOutputStream +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PluginValidatorEvalTest { + @TempDir + lateinit var tempDir: Path + + @Test + fun `tests valid directory passes validation`() { + val dir = File(tempDir.toFile(), "valid-dir") + dir.mkdirs() + + val manifest = + PluginManifest( + pluginId = "com.example.valid-plugin", + displayName = "Valid Plugin", + version = "1.0.0", + description = "A valid test plugin", + author = "Test Author", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.ValidPlugin", + requiredPermissions = listOf("mcp", "terminal"), + ) + File(dir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + + val result = PluginValidator.validate(dir) + assertTrue(result.isValid, "Valid directory should pass validation") + assertTrue(result.checks.all { it.passed }) + } + + @Test + fun `tests missing plugin json fails with exact diagnostic`() { + val emptyDir = File(tempDir.toFile(), "missing-manifest-dir") + emptyDir.mkdirs() + + val result = PluginValidator.validate(emptyDir) + assertFalse(result.isValid, "Directory without plugin.json must fail validation") + + val manifestCheck = result.checks.firstOrNull { it.name == "manifest-exists" } + assertNotNull(manifestCheck) + assertFalse(manifestCheck.passed) + assertTrue(manifestCheck.message.contains("plugin.json not found in directory")) + } + + @Test + fun `tests corrupt JSON fails cleanly without uncaught exception`() { + val corruptDir = File(tempDir.toFile(), "corrupt-json-dir") + corruptDir.mkdirs() + File(corruptDir, "plugin.json").writeText("{ id: 'broken', unclosed: ") + + val result = PluginValidator.validate(corruptDir) + assertFalse(result.isValid, "Corrupt JSON must fail validation") + + val jsonCheck = result.checks.firstOrNull { it.name == "manifest-json-valid" } + assertNotNull(jsonCheck) + assertFalse(jsonCheck.passed) + assertTrue(jsonCheck.message.contains("Malformed plugin.json")) + } + + @Test + fun `tests incompatible minApiVersion 999 fails`() { + val incompatibleDir = File(tempDir.toFile(), "incompatible-api-dir") + incompatibleDir.mkdirs() + + val manifest = + PluginManifest( + pluginId = "com.example.future-plugin", + displayName = "Future Plugin", + version = "1.0.0", + description = "Needs future API", + author = "Time Traveler", + apiVersion = "999.0.0", + mainClass = "com.example.FuturePlugin", + ) + File(incompatibleDir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + + val result = PluginValidator.validate(incompatibleDir) + assertFalse(result.isValid, "minApiVersion 999.0.0 must fail validation") + + val apiCheck = result.checks.firstOrNull { it.name == "min-api-version" } + assertNotNull(apiCheck) + assertFalse(apiCheck.passed) + assertTrue(apiCheck.message.contains("incompatible or exceeds host API version")) + } + + @Test + fun `tests synthetic JAR with missing class entry fails bytecode verification`() { + val jarFile = File(tempDir.toFile(), "missing-class.jar") + val manifest = + PluginManifest( + pluginId = "com.example.test-jar-plugin", + displayName = "Test Jar Plugin", + version = "0.2.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.MissingClass", + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + + // Create JAR with only plugin.json in META-INF/boss-plugin/, missing bytecode entry + createJar(jarFile, mapOf("META-INF/boss-plugin/plugin.json" to manifestBytes)) + + val result = PluginValidator.validate(jarFile) + assertFalse(result.isValid, "JAR missing bytecode entry must fail validation") + + val bytecodeCheck = result.checks.firstOrNull { it.name == "bytecode-entrypoint" } + assertNotNull(bytecodeCheck) + assertFalse(bytecodeCheck.passed) + val expectedMsg = "Bytecode entry 'com/example/MissingClass.class' not found in archive" + assertTrue(bytecodeCheck.message.contains(expectedMsg)) + } + + @Test + fun `tests synthetic JAR with present class entry passes bytecode verification`() { + val jarFile = File(tempDir.toFile(), "valid-class.jar") + val manifest = + PluginManifest( + pluginId = "com.example.test-jar-plugin", + displayName = "Test Jar Plugin", + version = "0.2.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = ValidatorTestFixturePlugin::class.java.name, + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + val classEntryPath = ValidatorTestFixturePlugin::class.java.name.replace('.', '/') + ".class" + val realClassBytes = + ValidatorTestFixturePlugin::class.java.classLoader + .getResourceAsStream(classEntryPath)!! + .readBytes() + + // Create JAR with plugin.json and real class bytes + createJar( + jarFile, + mapOf( + "META-INF/boss-plugin/plugin.json" to manifestBytes, + classEntryPath to realClassBytes, + ), + ) + + val result = PluginValidator.validate(jarFile) + val failedMessages = result.checks.filter { !it.passed }.map { it.message } + assertTrue( + result.isValid, + "JAR with present bytecode entry must pass validation: $failedMessages", + ) + + val bytecodeCheck = result.checks.firstOrNull { it.name == "bytecode-entrypoint" } + assertNotNull(bytecodeCheck) + assertTrue(bytecodeCheck.passed) + assertTrue(bytecodeCheck.message.contains("verified in archive")) + + val implementsCheck = result.checks.firstOrNull { it.name == "bytecode-implements-plugin" } + assertNotNull(implementsCheck) + assertTrue(implementsCheck.passed) + } + + @Test + fun `tests synthetic JAR with class not implementing Plugin fails bytecode verification`() { + val jarFile = File(tempDir.toFile(), "non-plugin-class.jar") + val manifest = + PluginManifest( + pluginId = "com.example.non-plugin", + displayName = "Non Plugin", + version = "0.2.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = NonPluginFixture::class.java.name, + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + val classEntryPath = NonPluginFixture::class.java.name.replace('.', '/') + ".class" + val realClassBytes = + NonPluginFixture::class.java.classLoader + .getResourceAsStream(classEntryPath)!! + .readBytes() + + createJar( + jarFile, + mapOf( + "META-INF/boss-plugin/plugin.json" to manifestBytes, + classEntryPath to realClassBytes, + ), + ) + + val result = PluginValidator.validate(jarFile) + assertFalse(result.isValid, "JAR with non-Plugin class must fail validation") + + val implementsCheck = result.checks.firstOrNull { it.name == "bytecode-implements-plugin" } + assertNotNull(implementsCheck) + assertFalse(implementsCheck.passed) + assertTrue(implementsCheck.message.contains("must implement ai.rever.boss.plugin.api.Plugin")) + } + + @Test + fun `tests reverse-domain dotted IDs pass validation`() { + val dir = File(tempDir.toFile(), "dotted-id-dir") + dir.mkdirs() + val manifest = + PluginManifest( + pluginId = "com.example.my.awesome.tool", + displayName = "Awesome Tool", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.AwesomeTool", + ) + File(dir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + val result = PluginValidator.validate(dir) + val idCheck = result.checks.firstOrNull { it.name == "id-format" } + assertNotNull(idCheck) + assertTrue(idCheck.passed, "com.example.my.awesome.tool must pass id-format check") + } + + @Test + fun `tests directory with resource manifest passes validation`() { + val dir = File(tempDir.toFile(), "resource-manifest-dir") + val metaInf = File(dir, "src/main/resources/META-INF/boss-plugin").apply { mkdirs() } + val manifest = + PluginManifest( + pluginId = "com.example.resource.plugin", + displayName = "Resource Plugin", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.ResourcePlugin", + ) + File(metaInf, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + val result = PluginValidator.validate(dir) + assertTrue(result.isValid, "Directory with resource manifest must pass validation") + } + + @Test + fun `tests SemVer 2 pre-release and build metadata pass validation`() { + val semVerCases = listOf("1.0.0-alpha.1", "0.2.0-rc.3", "0.1.0+build.2026") + for (versionStr in semVerCases) { + val dir = File(tempDir.toFile(), "semver-${versionStr.replace(Regex("[^a-zA-Z0-9]"), "-")}") + dir.mkdirs() + val manifest = + PluginManifest( + pluginId = "com.example.semver-plugin", + displayName = "SemVer Plugin", + version = versionStr, + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.ValidPlugin", + ) + File(dir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + val result = PluginValidator.validate(dir) + val versionCheck = result.checks.firstOrNull { it.name == "version-format" } + assertNotNull(versionCheck, "version-format check missing for $versionStr") + assertTrue(versionCheck.passed, "SemVer string '$versionStr' must validate as true") + } + } + + @Test + fun `tests canonical permissions vocabulary validation`() { + val dir = File(tempDir.toFile(), "canonical-perms-dir") + dir.mkdirs() + val allCanonical = + listOf( + "network", + "filesystem", + "terminal", + "browser", + "notifications", + "auth", + "mcp", + "editor", + "clipboard", + "settings", + "system", + "storage", + ) + val manifest = + PluginManifest( + pluginId = "com.example.perms-plugin", + displayName = "Permissions Plugin", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.ValidPlugin", + requiredPermissions = allCanonical, + ) + File(dir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + + val result = PluginValidator.validate(dir) + val permsCheck = result.checks.firstOrNull { it.name == "permissions" } + assertNotNull(permsCheck) + assertTrue(permsCheck.passed, "All canonical permissions must pass") + + // Test real RBAC permissions (plugins.create, secret.read) + val rbacDir = File(tempDir.toFile(), "rbac-perms-dir") + rbacDir.mkdirs() + val rbacManifest = + manifest.copy(requiredPermissions = listOf("plugins.create", "secret.read", "api_key.create")) + File(rbacDir, "plugin.json").writeText(launchpadJson.encodeToString(rbacManifest)) + val rbacResult = PluginValidator.validate(rbacDir) + val rbacCheck = rbacResult.checks.firstOrNull { it.name == "permissions" } + assertNotNull(rbacCheck) + assertTrue(rbacCheck.passed, "Real host RBAC permissions must pass validation") + + // Test invalid permission format (spaces/special characters) + val invalidDir = File(tempDir.toFile(), "invalid-perms-dir") + invalidDir.mkdirs() + val invalidManifest = manifest.copy(requiredPermissions = listOf("invalid permission with spaces!")) + File(invalidDir, "plugin.json").writeText(launchpadJson.encodeToString(invalidManifest)) + val invalidResult = PluginValidator.validate(invalidDir) + val invalidCheck = invalidResult.checks.firstOrNull { it.name == "permissions" } + assertNotNull(invalidCheck) + assertFalse(invalidCheck.passed, "Malformed permission identifier must fail") + } + + @Test + fun `tests dotless plugin ID fails id-format check`() { + val dir = File(tempDir.toFile(), "dotless-id-dir") + dir.mkdirs() + val manifest = + PluginManifest( + pluginId = "dotless-plugin", + displayName = "Dotless Plugin", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.DotlessPlugin", + ) + File(dir, "plugin.json").writeText(launchpadJson.encodeToString(manifest)) + val result = PluginValidator.validate(dir) + assertFalse(result.isValid, "Dotless plugin ID must fail validation") + val idCheck = result.checks.firstOrNull { it.name == "id-format" } + assertNotNull(idCheck) + assertFalse(idCheck.passed, "Dotless plugin ID must fail id-format check") + assertTrue(idCheck.message.contains("must follow reverse domain notation")) + } + + @Test + fun `tests JAR with root plugin json is rejected requiring META-INF path`() { + val jarFile = File(tempDir.toFile(), "root-manifest-only.jar") + val manifest = + PluginManifest( + pluginId = "com.example.root-manifest-plugin", + displayName = "Root Manifest Plugin", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = "com.example.SomeClass", + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + createJar( + jarFile, + mapOf("plugin.json" to manifestBytes), + ) + + val result = PluginValidator.validate(jarFile) + assertFalse(result.isValid, "JAR with root plugin.json must fail validation") + val manifestCheck = result.checks.firstOrNull { it.name == "manifest-exists" } + assertNotNull(manifestCheck) + assertFalse(manifestCheck.passed) + assertTrue( + manifestCheck.message.contains("must be located at META-INF/boss-plugin/plugin.json"), + "Expected diagnostic pointing to META-INF location, got: ${manifestCheck.message}", + ) + } + + @Test + fun `tests closed archive does not crash bytecode fallback parser`() { + val jarFile = File(tempDir.toFile(), "closed-archive-fallback.jar") + val unresolvableClassName = "com.example.synthetic.UnresolvablePlugin" + val manifest = + PluginManifest( + pluginId = "com.example.fallback-plugin", + displayName = "Fallback Plugin", + version = "1.0.0", + apiVersion = HostMeta.CURRENT_API_VERSION, + mainClass = unresolvableClassName, + ) + val manifestBytes = launchpadJson.encodeToString(manifest).toByteArray(StandardCharsets.UTF_8) + val classEntryPath = unresolvableClassName.replace('.', '/') + ".class" + // Class bytes inherit from a missing superclass absent from the JAR and classpath, + // forcing Class.forName to throw NoClassDefFoundError and exercising the manual bytecode parser. + val syntheticClassBytes = + generateSyntheticPluginClassBytes( + className = unresolvableClassName.replace('.', '/'), + superClassName = "com/example/missing/MissingSuperClass", + interfaceName = "ai/rever/boss/plugin/api/Plugin", + ) + + createJar( + jarFile, + mapOf( + "META-INF/boss-plugin/plugin.json" to manifestBytes, + classEntryPath to syntheticClassBytes, + ), + ) + + val result = PluginValidator.validate(jarFile) + assertTrue(result.isValid, "Valid JAR must pass validation without closed zip errors") + val implementsCheck = result.checks.firstOrNull { it.name == "bytecode-implements-plugin" } + assertNotNull(implementsCheck) + assertTrue( + implementsCheck.passed, + "Fallback bytecode parser must detect direct Plugin interface implementation", + ) + } + + @Test + fun `tests truncated classfile bytes returns false without throwing BufferUnderflowException`() { + val truncatedBytes = + byteArrayOf(0xCA.toByte(), 0xFE.toByte(), 0xBA.toByte(), 0xBE.toByte(), 0, 0, 0, 52, 0, 2) + assertFalse( + PluginValidator.checkDirectInterfaceImplementation(truncatedBytes), + "Truncated bytecode must safely return false and not throw BufferUnderflowException", + ) + } + + @Test + fun `manifest exceeding 512 KB limit fails validation safely`() { + val jarFile = File(tempDir.toFile(), "oversized-manifest.jar") + val padding = "x".repeat(525 * 1024) + val oversizedJson = """{"pluginId":"com.example.big","version":"1.0.0","description":"$padding"}""" + createJar( + jarFile, + mapOf("META-INF/boss-plugin/plugin.json" to oversizedJson.toByteArray(StandardCharsets.UTF_8)), + ) + + val result = PluginValidator.validate(jarFile) + assertFalse(result.isValid, "JAR with manifest > 512 KB must fail validation") + val manifestCheck = result.checks.firstOrNull { it.name == "manifest-valid" } + assertNotNull(manifestCheck, "Must record manifest-valid failure check") + assertFalse(manifestCheck.passed) + assertTrue(manifestCheck.message.contains("512 KB")) + + val manifest = PluginValidator.readManifestFromJar(jarFile) + assertNull(manifest, "readManifestFromJar must return null for manifest exceeding limit") + assertFalse( + DevPluginArtifacts.isValidDevJar(jarFile), + "DevPluginArtifacts.isValidDevJar must reject oversized manifest", + ) + } + + private fun generateSyntheticPluginClassBytes( + className: String, + superClassName: String, + interfaceName: String, + ): ByteArray { + val baos = java.io.ByteArrayOutputStream() + val dos = java.io.DataOutputStream(baos) + dos.writeInt(0xCAFEBABE.toInt()) + dos.writeShort(0) // minor + dos.writeShort(52) // major (Java 8) + dos.writeShort(7) // cp count = 7 (indices 1..6) + // 1: Utf8 className + dos.writeByte(1) + dos.writeUTF(className) + // 2: Class className + dos.writeByte(7) + dos.writeShort(1) + // 3: Utf8 superClassName + dos.writeByte(1) + dos.writeUTF(superClassName) + // 4: Class superClassName + dos.writeByte(7) + dos.writeShort(3) + // 5: Utf8 interfaceName + dos.writeByte(1) + dos.writeUTF(interfaceName) + // 6: Class interfaceName + dos.writeByte(7) + dos.writeShort(5) + // access flags + dos.writeShort(0x0021) + // this_class + dos.writeShort(2) + // super_class + dos.writeShort(4) + // interfaces + dos.writeShort(1) + dos.writeShort(6) + // fields + dos.writeShort(0) + // methods + dos.writeShort(0) + // attributes + dos.writeShort(0) + dos.flush() + return baos.toByteArray() + } + + private fun createJar( + jarFile: File, + entries: Map, + ) { + JarOutputStream(FileOutputStream(jarFile)).use { jos -> + for ((name, bytes) in entries) { + val entry = JarEntry(name) + jos.putNextEntry(entry) + jos.write(bytes) + jos.closeEntry() + } + } + } +} + +class ValidatorTestFixturePlugin : ai.rever.boss.plugin.api.Plugin { + override val pluginId = "com.example.test-jar-plugin" + override val displayName = "Test Jar Plugin" + + override fun register(context: ai.rever.boss.plugin.api.PluginContext) { + // No-op for test fixture + } +} + +class NonPluginFixture { + val someProperty = "not a plugin" +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloaderTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloaderTest.kt new file mode 100644 index 0000000000..3f38795aaa --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginReloaderTest.kt @@ -0,0 +1,193 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.components.plugin.DynamicPluginInfo +import ai.rever.boss.components.plugin.DynamicPluginManager +import ai.rever.boss.plugin.api.PanelRegistry +import ai.rever.boss.plugin.api.PluginState +import ai.rever.boss.plugin.api.TabRegistry +import ai.rever.boss.plugin.sandbox.PluginSandboxManagerImpl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import ai.rever.boss.plugin.api.PluginManifest as ApiPluginManifest + +class DevPluginReloaderTest { + @TempDir + lateinit var tempDir: Path + + private val activeManagersToClean = mutableListOf() + + @BeforeTest + fun setUp() { + DevPluginArtifacts.stagingRootOverride = tempDir.resolve("dev-root").toFile().apply { mkdirs() } + } + + @AfterTest + fun tearDown() { + runBlocking { + activeManagersToClean.forEach { + runCatching { it.disposeWindow() } + } + activeManagersToClean.clear() + } + DevPluginArtifacts.stagingRootOverride = null + } + + private fun createManager(): DynamicPluginManager { + val sandboxManager = PluginSandboxManagerImpl() + val manager = + DynamicPluginManager( + PanelRegistry(), + TabRegistry(), + sandboxManager, + createSandboxedContext = { _, _ -> error("Test fixture context") }, + ) + activeManagersToClean.add(manager) + return manager + } + + @Suppress("UNCHECKED_CAST") + private fun setPluginInfo( + manager: DynamicPluginManager, + pluginId: String, + info: DynamicPluginInfo, + ) { + val field = DynamicPluginManager::class.java.getDeclaredField("_pluginStates").apply { isAccessible = true } + val flow = field.get(manager) as MutableStateFlow> + flow.value = flow.value + (pluginId to info) + } + + @Test + fun `four successive stages across two managers preserves open older jar`() { + val pluginId = "multi-window-plugin" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val pluginDevDir = File(stagingRoot, pluginId).apply { mkdirs() } + + // 1. Window 1 and Window 2 both load v1 (staged at v1000) + // 2. Perform stage + reload to v2 (both managers update to v2000) + // 3. Window 2 holds a lock/handle on v2, while Window 1 performs successive links to v3, v4, and v5 + val timestamps = listOf(1000L, 2000L, 3000L, 4000L, 5000L) + val jarFiles = mutableMapOf() + for (ts in timestamps) { + val vDir = File(pluginDevDir, "v$ts").apply { mkdirs() } + val jar = File(vDir, "$pluginId.jar") + jar.writeText("content-$ts") + jarFiles[ts] = jar + } + + val v2Jar = jarFiles[2000L]!! + val activeJarPaths = setOf(v2Jar.absolutePath) + + // 4. Assert that DevPluginArtifacts.pruneStagingHistory prunes unreferenced versions, + // but strictly retains v2 because Window 2's manager still lists v2 in activeJarPaths. + DevPluginArtifacts.pruneStagingHistory( + pluginDevDir = pluginDevDir, + maxVersionsToKeep = 3, + activeJarPaths = activeJarPaths, + ) + + val remaining = + pluginDevDir + .listFiles { file -> file.isDirectory && file.name.startsWith("v") } + ?.map { it.name } + ?.sorted() + ?: emptyList() + + // v1000 is pruned because it is not active and older than top-3 newest (v3000, v4000, v5000). + // v2000 MUST be retained because Window 2 still holds an active handle on it! + // v3000, v4000, v5000 are the top 3 newest versions. + assertEquals( + listOf("v2000", "v3000", "v4000", "v5000"), + remaining, + "v2000 must be retained because it is actively referenced by Window 2", + ) + } + + @Test + fun `two managers dry-run pre-flight check aborts if one manager has protected plugin`() = + runBlocking { + val pluginId = "protected-test-plugin" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val vDir = File(stagingRoot, "$pluginId/v1000").apply { mkdirs() } + File(vDir, "$pluginId.jar").writeText("valid-dev-jar") + + val manager1 = createManager() + val manager2 = createManager() + + // Manager 1 can unload + val info1 = + DynamicPluginInfo( + manifest = + ApiPluginManifest( + pluginId = pluginId, + displayName = "Test Plugin", + version = "1.0.0", + apiVersion = "1.0.0", + mainClass = "test.Main", + canUnload = true, + ), + jarPath = File(vDir, "$pluginId.jar").absolutePath, + state = PluginState.LOADED, + loadedAt = System.currentTimeMillis(), + enabled = true, + ) + setPluginInfo(manager1, pluginId, info1) + + // Manager 2 cannot unload (system protected / canUnload=false) + val info2 = + DynamicPluginInfo( + manifest = + ApiPluginManifest( + pluginId = pluginId, + displayName = "Test Plugin", + version = "1.0.0", + apiVersion = "1.0.0", + mainClass = "test.Main", + canUnload = false, + ), + jarPath = File(vDir, "$pluginId.jar").absolutePath, + state = PluginState.LOADED, + loadedAt = System.currentTimeMillis(), + enabled = true, + ) + setPluginInfo(manager2, pluginId, info2) + + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Pre-flight check must abort reload across all managers") + val exception = result.exceptionOrNull() + assertTrue( + exception?.message?.contains("protected system plugin") == true, + "Expected protected plugin error message, got: ${exception?.message}", + ) + + // Manager 1's plugin state must remain untouched (never unloaded) + assertEquals(PluginState.LOADED, manager1.getPluginInfo(pluginId)?.state) + assertEquals(PluginState.LOADED, manager2.getPluginInfo(pluginId)?.state) + } + + @Test + fun `reload fails when no host manager is initialized`() = + runBlocking { + val result = DevPluginReloader.reload("non-existent-plugin") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("not yet initialized") == true) + } + + @Test + fun `reload fails when staged dev JAR is missing`() = + runBlocking { + createManager() + val result = DevPluginReloader.reload("missing-jar-plugin") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("No staged dev JAR found") == true) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginRollbackTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginRollbackTest.kt new file mode 100644 index 0000000000..30739b3cb3 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/DevPluginRollbackTest.kt @@ -0,0 +1,947 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.cli.plugin.ValidatorTestFixturePlugin +import ai.rever.boss.components.plugin.DefaultPlugin +import ai.rever.boss.components.plugin.DynamicPluginInfo +import ai.rever.boss.components.plugin.DynamicPluginManager +import ai.rever.boss.plugin.PluginPersistence +import ai.rever.boss.plugin.PluginStoreSetup +import ai.rever.boss.plugin.api.CanUnloadResult +import ai.rever.boss.plugin.api.PanelRegistry +import ai.rever.boss.plugin.api.PluginContext +import ai.rever.boss.plugin.api.PluginState +import ai.rever.boss.plugin.api.PluginUnloadAware +import ai.rever.boss.plugin.api.TabRegistry +import ai.rever.boss.plugin.sandbox.PluginSandboxManagerImpl +import ai.rever.boss.plugin.sandbox.SandboxConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Path +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Suppress("LargeClass") +class DevPluginRollbackTest { + @TempDir + lateinit var tempDir: Path + + private val activeManagersToClean = mutableListOf() + + @BeforeTest + fun setUp() { + DevPluginArtifacts.stagingRootOverride = tempDir.resolve("dev-root").toFile().apply { mkdirs() } + DevPluginReloader.clearSessionPreservedPathsForTest() + } + + @AfterTest + fun tearDown() { + runBlocking { + activeManagersToClean.forEach { + runCatching { it.disposeWindow() } + } + activeManagersToClean.clear() + } + DevPluginArtifacts.stagingRootOverride = null + DevPluginReloader.clearSessionPreservedPathsForTest() + } + + private class TestPluginContext( + override val panelRegistry: PanelRegistry = PanelRegistry(), + override val tabRegistry: TabRegistry = TabRegistry(), + override val pluginScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + ) : PluginContext + + private fun createManager( + onSandboxedContext: ((pluginId: String, config: SandboxConfig) -> PluginContext)? = null, + ): DynamicPluginManager { + val sandboxManager = PluginSandboxManagerImpl() + val dummyContext = TestPluginContext() + val manager = + DynamicPluginManager( + dummyContext.panelRegistry, + dummyContext.tabRegistry, + sandboxManager, + createSandboxedContext = { pluginId, config -> + onSandboxedContext?.invoke(pluginId, config) ?: dummyContext + }, + ) + activeManagersToClean.add(manager) + return manager + } + + private fun createJar( + file: File, + entries: Map, + ) { + file.parentFile?.mkdirs() + JarOutputStream(FileOutputStream(file)).use { jos -> + entries.forEach { (name, bytes) -> + jos.putNextEntry(JarEntry(name)) + jos.write(bytes) + } + } + } + + private fun createDevTestJar( + stagingRoot: File, + pluginId: String, + versionDir: String, + version: String, + mainClass: String = ValidatorTestFixturePlugin::class.java.name, + includeMainClassBytecode: Boolean = true, + ): File { + val dir = File(stagingRoot, "$pluginId/$versionDir").apply { mkdirs() } + val jar = File(dir, "$pluginId.jar") + val manifest = + """ + { + "manifestVersion": 1, + "pluginId": "$pluginId", + "displayName": "Hot Reload Tool $version", + "version": "$version", + "apiVersion": "1.0.0", + "mainClass": "$mainClass" + } + """.trimIndent().toByteArray(Charsets.UTF_8) + val entries = + mutableMapOf( + "META-INF/boss-plugin/plugin.json" to manifest, + ) + if (includeMainClassBytecode) { + val classEntryPath = mainClass.replace('.', '/') + ".class" + val classBytes = + ValidatorTestFixturePlugin::class.java.classLoader + .getResourceAsStream(classEntryPath)!! + .readBytes() + entries[classEntryPath] = classBytes + } + createJar(jar, entries) + return jar + } + + private fun createStoreTestJar( + storeJar: File, + pluginId: String, + version: String = "1.0.0", + ) { + val classEntryPath = ValidatorTestFixturePlugin::class.java.name.replace('.', '/') + ".class" + val classBytes = + ValidatorTestFixturePlugin::class.java.classLoader + .getResourceAsStream(classEntryPath)!! + .readBytes() + val storeManifest = + """ + { + "manifestVersion": 1, + "pluginId": "$pluginId", + "displayName": "Store Build $version", + "version": "$version", + "apiVersion": "1.0.0", + "mainClass": "${ValidatorTestFixturePlugin::class.java.name}" + } + """.trimIndent().toByteArray(Charsets.UTF_8) + createJar( + storeJar, + mapOf( + "META-INF/boss-plugin/plugin.json" to storeManifest, + classEntryPath to classBytes, + ), + ) + } + + private fun assertRestoredToV1( + manager: DynamicPluginManager, + pluginId: String, + expectedJar: File, + ) { + val restored = manager.getPluginInfo(pluginId) + assertNotNull(restored, "Manager must have a restored plugin after rollback") + assertEquals( + expectedJar.canonicalPath, + File(restored.jarPath).canonicalPath, + "Manager must have restored to v1.jar", + ) + assertEquals(PluginState.LOADED, restored.state, "Restored plugin must be LOADED") + assertTrue(restored.enabled, "Restored plugin must be enabled") + assertEquals("1.0.0", restored.manifest.version, "Must be on v1 (1.0.0)") + } + + private fun assertRestoredToV1Disabled( + manager: DynamicPluginManager, + pluginId: String, + expectedJar: File, + ) { + val restored = manager.getPluginInfo(pluginId) + assertNotNull(restored, "Manager must have a restored plugin after rollback") + assertEquals( + expectedJar.canonicalPath, + File(restored.jarPath).canonicalPath, + "Manager must have restored to v1.jar", + ) + assertEquals(PluginState.DISABLED, restored.state, "Restored plugin must be DISABLED") + assertFalse(restored.enabled, "Restored plugin must be disabled") + assertEquals("1.0.0", restored.manifest.version, "Must be on v1 (1.0.0)") + } + + @Test + fun `first link failure uninstalls partially installed plugin across managers to restore clean initial state`() = + runBlocking { + val pluginId = "first-link-tool" + val stagingRoot = DevPluginArtifacts.stagingRoot() + createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var manager2FailLink = true + val manager2 = + createManager { _, _ -> + if (manager2FailLink) { + manager2FailLink = false + error("Simulated link failure in manager 2") + } + dummyContext + } + + assertNull(manager1.getPluginInfo(pluginId)) + assertNull(manager2.getPluginInfo(pluginId)) + + // Drive through public DevPluginReloader.reload + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail when second manager fails install") + + assertNull( + manager1.getPluginInfo(pluginId), + "Manager 1 must have uninstalled the newly linked plugin after rollback", + ) + assertNull( + manager2.getPluginInfo(pluginId), + "Manager 2 must remain in clean state without plugin", + ) + } + + @Test + fun `hot reload failure in second manager restores prior jar in first manager`() = + runBlocking { + val pluginId = "com.example.hotreload" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var manager2FailV2Install = false + val manager2 = + createManager { _, _ -> + if (manager2FailV2Install) { + manager2FailV2Install = false + error("Simulated v2 install failure on manager 2") + } + dummyContext + } + + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must load v1.jar successfully") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res2.isSuccess, "Manager 2 must load v1.jar successfully") + + assertEquals( + v1Jar.canonicalPath, + File(manager1.getPluginInfo(pluginId)!!.jarPath).canonicalPath, + ) + assertEquals( + v1Jar.canonicalPath, + File(manager2.getPluginInfo(pluginId)!!.jarPath).canonicalPath, + ) + + // Stage v2.jar and arm manager 2 to fail v2 install + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + manager2FailV2Install = true + + // Drive through public DevPluginReloader.reload + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail when manager 2 fails v2 install") + + assertRestoredToV1(manager1, pluginId, v1Jar) + assertRestoredToV1(manager2, pluginId, v1Jar) + } + + @Test + fun `hot reload failure in second manager restores disabled prior jar in first manager with wasEnabled false`() = + runBlocking { + val pluginId = "com.example.disabled.rollback" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var manager2FailV2Install = false + val manager2 = + createManager { _, _ -> + if (manager2FailV2Install) { + manager2FailV2Install = false + error("Simulated v2 install failure on manager 2") + } + dummyContext + } + + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = false) + assertTrue(res1.isSuccess, "Manager 1 must load v1.jar successfully") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = false) + assertTrue(res2.isSuccess, "Manager 2 must load v1.jar successfully") + + assertEquals(PluginState.DISABLED, manager1.getPluginInfo(pluginId)!!.state) + assertFalse(manager1.getPluginInfo(pluginId)!!.enabled) + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + manager2FailV2Install = true + + // Drive through public DevPluginReloader.reload + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail when manager 2 fails v2 install") + + assertRestoredToV1Disabled(manager1, pluginId, v1Jar) + assertRestoredToV1Disabled(manager2, pluginId, v1Jar) + } + + @Test + fun `reload with late refusal leaves refusing manager untouched`() = + runBlocking { + val pluginId = "com.example.laterefusal" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val manager1 = createManager() + val manager2 = createManager() + + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must install v1.jar") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res2.isSuccess, "Manager 2 must install v1.jar") + + val initialManager2Info = manager2.getPluginInfo(pluginId) + assertNotNull(initialManager2Info) + + var manager1Unloaded = false + manager1.registerUnloadAware( + object : PluginUnloadAware { + override fun checkCanUnload(pluginId: String): CanUnloadResult = CanUnloadResult.Ok + + override fun prepareForUnload(pluginId: String) { + manager1Unloaded = true + } + }, + ) + manager2.registerUnloadAware( + object : PluginUnloadAware { + override fun checkCanUnload(pluginId: String): CanUnloadResult = + if (!manager1Unloaded) { + CanUnloadResult.Ok + } else { + CanUnloadResult.NotAllowed(listOf("Late refusal from manager 2")) + } + + override fun prepareForUnload(pluginId: String) { + // No preparation needed; manager doesn't hold unloadable resources + } + }, + ) + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail when manager 2 refuses unload") + + // Manager 1 had unloaded, so rollback cleanly restores its prior v1.jar + assertRestoredToV1(manager1, pluginId, v1Jar) + + // Manager 2 refused unload, so it was never modified; rollback leaves it completely untouched + assertRestoredToV1(manager2, pluginId, v1Jar) + val currentManager2Info = manager2.getPluginInfo(pluginId) + assertNotNull(currentManager2Info) + assertEquals( + initialManager2Info.loadedAt, + currentManager2Info.loadedAt, + "Manager 2 was untouched and must retain its exact original loaded instance", + ) + } + + @Test + fun `startup fallback loads store jar when staged dev jar fails`() = + runBlocking { + val pluginId = "com.example.startup.fallback" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val storeDir = tempDir.resolve("store-builds").toFile().apply { mkdirs() } + val storeJar = File(storeDir, "$pluginId.jar") + createStoreTestJar(storeJar, pluginId, "1.0.0") + + // Create a dev jar that passes archive and manifest validation, but points to a non-existent mainClass + createDevTestJar( + stagingRoot = stagingRoot, + pluginId = pluginId, + versionDir = "v2000", + version = "2.0.0", + mainClass = "non.existent.BrokenPluginMain", + includeMainClassBytecode = false, + ) + + val manager = createManager() + val storeEntry = + PluginPersistence.InstalledPluginEntry( + pluginId = pluginId, + jarPath = storeJar.absolutePath, + enabled = true, + ) + + val results = + PluginStoreSetup.loadPersistedPluginEntries( + dynamicPluginManager = manager, + persistedPlugins = listOf(storeEntry), + devRoot = stagingRoot, + ) + + val pluginResult = results[pluginId] + assertNotNull(pluginResult, "Must have a result for plugin") + assertTrue( + pluginResult.isSuccess, + "Plugin must successfully fall back to store build: ${pluginResult.exceptionOrNull()}", + ) + + val loadedInfo = manager.getPluginInfo(pluginId) + assertNotNull(loadedInfo, "Plugin must be loaded in manager") + assertEquals(storeJar.canonicalPath, File(loadedInfo.jarPath).canonicalPath, "Must load store jar path") + assertEquals(PluginState.LOADED, loadedInfo.state, "Fallback plugin must be LOADED") + assertEquals("1.0.0", loadedInfo.manifest.version, "Must be on store version 1.0.0") + } + + @Test + fun `reload of disabled plugin preserves disabled state across managers without throwing`() = + runBlocking { + val pluginId = "com.example.disabled.reload" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val manager1 = createManager() + val manager2 = createManager() + + // 1. Both managers start with v1.jar installed and DISABLED + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = false) + assertTrue(res1.isSuccess, "Manager 1 must install v1.jar") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = false) + assertTrue(res2.isSuccess, "Manager 2 must install v1.jar") + + assertEquals(PluginState.DISABLED, manager1.getPluginInfo(pluginId)?.state) + assertFalse(manager1.getPluginInfo(pluginId)!!.enabled) + + // 2. Stage v2.jar + val v2Jar = createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + + // 3. Perform full DevPluginReloader.reload + val reloadResult = DevPluginReloader.reload(pluginId, stagingRoot) + val errorMsg = reloadResult.exceptionOrNull()?.message + assertTrue(reloadResult.isSuccess, "Reload of disabled plugin must succeed: $errorMsg") + + // 4. Verify both managers updated to v2.jar and remain DISABLED + val info1 = manager1.getPluginInfo(pluginId) + assertNotNull(info1) + assertEquals(File(v2Jar.absolutePath).canonicalPath, File(info1.jarPath).canonicalPath) + assertEquals(PluginState.DISABLED, info1.state) + assertFalse(info1.enabled, "Manager 1 must preserve disabled state") + assertEquals("2.0.0", info1.manifest.version) + + val info2 = manager2.getPluginInfo(pluginId) + assertNotNull(info2) + assertEquals(File(v2Jar.absolutePath).canonicalPath, File(info2.jarPath).canonicalPath) + assertEquals(PluginState.DISABLED, info2.state) + assertFalse(info2.enabled, "Manager 2 must preserve disabled state") + assertEquals("2.0.0", info2.manifest.version) + } + + @Test + fun `isValidDevJar validates readable zip and manifest content`() { + val validJar = tempDir.resolve("valid.jar").toFile() + val manifestJson = """{"id": "test-plugin", "version": "1.0.0"}""".toByteArray() + createJar(validJar, mapOf("META-INF/boss-plugin/plugin.json" to manifestJson)) + + assertTrue(DevPluginArtifacts.isValidDevJar(validJar, "test-plugin")) + assertTrue(DevPluginArtifacts.isValidDevJar(validJar, null)) + assertFalse(DevPluginArtifacts.isValidDevJar(validJar, "mismatched-id")) + + // Corrupt zip (random bytes) + val corruptJar = tempDir.resolve("corrupt.jar").toFile() + corruptJar.writeBytes(byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04)) + assertFalse(DevPluginArtifacts.isValidDevJar(corruptJar)) + + // Empty file + val emptyJar = tempDir.resolve("empty.jar").toFile() + emptyJar.writeBytes(byteArrayOf()) + assertFalse(DevPluginArtifacts.isValidDevJar(emptyJar)) + + // Missing manifest entry + val noManifestJar = tempDir.resolve("no-manifest.jar").toFile() + createJar(noManifestJar, mapOf("dummy.txt" to "hello".toByteArray())) + assertFalse(DevPluginArtifacts.isValidDevJar(noManifestJar)) + + // Part file + val partJar = tempDir.resolve("plugin.jar.part").toFile() + createJar(partJar, mapOf("META-INF/boss-plugin/plugin.json" to manifestJson)) + assertFalse(DevPluginArtifacts.isValidDevJar(partJar)) + } + + @Test + fun `findAllActiveDevJars with deepValidate filters out invalid dev jars`() { + val devRoot = tempDir.resolve("staging-test").toFile().apply { mkdirs() } + + // Corrupt dev jar under plugin-corrupt + val corruptDir = File(devRoot, "plugin-corrupt/v1000").apply { mkdirs() } + File(corruptDir, "plugin-corrupt.jar").writeBytes(byteArrayOf(1, 2, 3, 4, 5)) + + // Valid dev jar under plugin-valid + val validDir = File(devRoot, "plugin-valid/v2000").apply { mkdirs() } + val manifestBytes = """{"id": "plugin-valid", "version": "1.0.0"}""".toByteArray() + val validJar = File(validDir, "plugin-valid.jar") + createJar(validJar, mapOf("META-INF/boss-plugin/plugin.json" to manifestBytes)) + + val allDiscovered = DevPluginArtifacts.findAllActiveDevJars(devRoot, deepValidate = true) + assertEquals(1, allDiscovered.size) + assertEquals(validJar.absolutePath, allDiscovered.first().absolutePath) + } + + @Test + fun `partial install failure on window 1 leaves window 2 untouched`() = + runBlocking { + val pluginId = "com.example.partial.install" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val dummyContext = TestPluginContext() + var manager1FailInstall = false + val manager1 = + createManager { _, _ -> + if (manager1FailInstall) { + manager1FailInstall = false + error("Simulated v2 install failure on manager 1") + } + dummyContext + } + val manager2 = createManager() + + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must install v1.jar") + assertNull(manager2.getPluginInfo(pluginId), "Manager 2 must have no plugin") + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + manager1FailInstall = true + + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail when manager 1 fails install") + + assertRestoredToV1(manager1, pluginId, v1Jar) + assertNull(manager2.getPluginInfo(pluginId), "Manager 2 was untouched and remains null") + } + + @Test + fun `startup fallback recovers store jar on binary incompatibility`() = + runBlocking { + val pluginId = "com.example.binary.compat" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val storeDir = tempDir.resolve("store-compat").toFile().apply { mkdirs() } + val storeJar = File(storeDir, "$pluginId.jar") + createStoreTestJar(storeJar, pluginId, "1.0.0") + + createDevTestJar( + stagingRoot = stagingRoot, + pluginId = pluginId, + versionDir = "v2000", + version = "2.0.0", + mainClass = IncompatibleBinaryPlugin::class.java.name, + includeMainClassBytecode = true, + ) + + val manager = createManager() + val storeEntry = + PluginPersistence.InstalledPluginEntry( + pluginId = pluginId, + jarPath = storeJar.absolutePath, + enabled = true, + ) + + val results = + PluginStoreSetup.loadPersistedPluginEntries( + dynamicPluginManager = manager, + persistedPlugins = listOf(storeEntry), + devRoot = stagingRoot, + ) + + val pluginResult = results[pluginId] + assertNotNull(pluginResult, "Must have a result for plugin") + assertTrue( + pluginResult.isSuccess, + "Plugin must fall back to store build on binary incompatibility: ${pluginResult.exceptionOrNull()}", + ) + + val loadedInfo = manager.getPluginInfo(pluginId) + assertNotNull(loadedInfo, "Plugin must be loaded in manager") + assertEquals(storeJar.canonicalPath, File(loadedInfo.jarPath).canonicalPath) + assertEquals(PluginState.LOADED, loadedInfo.state) + assertEquals("1.0.0", loadedInfo.manifest.version) + } + + @Test + fun `persisted startup keeps the installed build for a protected prefix id`() { + val pluginId = "ai.rever.boss.system.fixture" + val stagingRoot = DevPluginArtifacts.stagingRoot() + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + val storeJar = tempDir.resolve("protected-store.jar").toFile() + createStoreTestJar(storeJar, pluginId, "1.0.0") + val entry = PluginPersistence.InstalledPluginEntry(pluginId, storeJar.absolutePath, enabled = true) + + assertEquals(storeJar.absolutePath, PluginStoreSetup.resolvePersistedEntryPath(entry, stagingRoot)) + } + + @Test + fun `external scan falls back to standard jar on dev failure`() = + runBlocking { + val pluginId = "com.example.ext.fallback" + val pluginDir = tempDir.resolve("ext-plugins").toFile().apply { mkdirs() } + val standardJar = File(pluginDir, "$pluginId.jar") + createStoreTestJar(standardJar, pluginId, "1.0.0") + + val devRoot = File(pluginDir, "dev").apply { mkdirs() } + DevPluginArtifacts.stagingRootOverride = devRoot + try { + val devJar = + createDevTestJar( + stagingRoot = devRoot, + pluginId = pluginId, + versionDir = "v2000", + version = "2.0.0", + mainClass = "non.existent.BrokenClass", + includeMainClassBytecode = false, + ) + + val manager = createManager() + val defaultPlugin = + DefaultPlugin( + panelRegistry = PanelRegistry(), + tabRegistry = TabRegistry(), + windowProjectState = null, + ) + + defaultPlugin.installSingleExternalPlugin( + manager = manager, + jarFile = devJar, + trackedJarPaths = emptySet(), + fallbackStandardJar = standardJar, + ) + + val loaded = manager.getPluginInfo(pluginId) + assertNotNull(loaded, "Manager must load fallback standard plugin") + assertEquals(standardJar.canonicalPath, File(loaded.jarPath).canonicalPath) + assertEquals(PluginState.LOADED, loaded.state) + assertEquals("1.0.0", loaded.manifest.version) + } finally { + DevPluginArtifacts.stagingRootOverride = null + } + } + + @Test + fun `failed restoration in rollback attaches suppressed exception`() = + runBlocking { + val pluginId = "com.example.failed.restore" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = + createDevTestJar( + stagingRoot = stagingRoot, + pluginId = pluginId, + versionDir = "v1000", + version = "1.0.0", + mainClass = ControllableFailingPlugin::class.java.name, + includeMainClassBytecode = true, + ) + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var failManager2Install = false + val manager2 = + createManager { _, _ -> + if (failManager2Install) { + failManager2Install = false + error("Simulated v2 install failure on manager 2") + } + dummyContext + } + + System.clearProperty("boss.test.fail_rollback_register") + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must install v1.jar") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res2.isSuccess, "Manager 2 must install v1.jar") + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + failManager2Install = true + System.setProperty("boss.test.fail_rollback_register", "true") + + try { + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail") + val root = result.exceptionOrNull() + assertNotNull(root, "Root exception must be present") + assertTrue( + root.suppressedExceptions.isNotEmpty(), + "Expected recovery failure to be attached as suppressed exception", + ) + assertTrue( + root.suppressedExceptions.any { + it.message?.contains("Failed to reinstall prior JAR") == true + }, + "Suppressed exceptions must contain rollback reinstall failure", + ) + } finally { + System.clearProperty("boss.test.fail_rollback_register") + } + } + + @Test + fun `binary incompatible restoration is reported as rollback failure`() = + runBlocking { + val pluginId = "com.example.failed.restore" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = + createDevTestJar( + stagingRoot = stagingRoot, + pluginId = pluginId, + versionDir = "v1000", + version = "1.0.0", + mainClass = ControllableFailingPlugin::class.java.name, + includeMainClassBytecode = true, + ) + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var failManager2Install = false + val manager2 = + createManager { _, _ -> + if (failManager2Install) { + failManager2Install = false + error("Simulated v2 install failure on manager 2") + } + dummyContext + } + + System.clearProperty("boss.test.fail_rollback_binary") + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must install v1.jar") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res2.isSuccess, "Manager 2 must install v1.jar") + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + failManager2Install = true + System.setProperty("boss.test.fail_rollback_binary", "true") + + try { + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload must fail") + val root = result.exceptionOrNull() + assertNotNull(root, "Root exception must be present") + assertTrue( + root.suppressedExceptions.isNotEmpty(), + "Expected recovery failure to be attached as suppressed exception", + ) + assertTrue( + root.suppressedExceptions.any { + it.message?.contains("Failed to reinstall prior JAR") == true + }, + "Suppressed exceptions must contain rollback reinstall failure", + ) + } finally { + System.clearProperty("boss.test.fail_rollback_binary") + } + } + + @Test + fun `protected dev candidate is dropped before deduplication`() { + val pluginId = ai.rever.boss.components.plugin.MicrokernelRuntime.PLUGIN_ID + val stagingRoot = DevPluginArtifacts.stagingRoot() + val devJar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val deduplicated = + DefaultPlugin.deduplicateJars(listOf(devJar)) { id -> + DefaultPlugin.isAuthoritativeSystemPlugin(id) + } + + assertTrue( + deduplicated.isEmpty(), + "Lone dev JAR claiming authoritative protected ID must be dropped before deduplication", + ) + } + + @Test + fun `repeated reloads preserve session retained jar paths`() = + runBlocking { + val pluginId = "com.example.repeated.retention" + val stagingRoot = DevPluginArtifacts.stagingRoot() + createManager() + + val jars = + (1..5).map { v -> + val jar = createDevTestJar(stagingRoot, pluginId, "v${v}000", "$v.0.0") + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isSuccess, "Reload v$v must succeed") + jar + } + + jars.forEach { jar -> + assertTrue( + jar.exists(), + "Session retained JAR ${jar.name} must not be deleted by repeated reloads", + ) + } + } + + @Test + fun `overlapping reloads on same plugin are serialized by mutex`() = + runBlocking { + val pluginId = "com.example.mutex.reload" + val stagingRoot = DevPluginArtifacts.stagingRoot() + createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val manager = createManager() + val res1 = + manager.installPlugin( + File(stagingRoot, "$pluginId/v1000/$pluginId.jar").absolutePath, + enabled = true, + ) + assertTrue(res1.isSuccess) + + createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + + val job1 = + async(Dispatchers.Default) { + DevPluginReloader.reload(pluginId, stagingRoot) + } + val job2 = + async(Dispatchers.Default) { + DevPluginReloader.reload(pluginId, stagingRoot) + } + + val result1 = job1.await() + val result2 = job2.await() + + assertTrue(result1.isSuccess, "First concurrent reload must succeed") + assertTrue(result2.isSuccess, "Second concurrent reload must succeed") + assertEquals("2.0.0", manager.getPluginInfo(pluginId)?.manifest?.version) + } + + @Test + fun `failed reload jar is retained across subsequent successful reloads`() = + runBlocking { + DevPluginReloader.clearSessionPreservedPathsForTest() + val pluginId = "com.example.failed.retention" + val stagingRoot = DevPluginArtifacts.stagingRoot() + val v1Jar = createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val dummyContext = TestPluginContext() + val manager1 = createManager() + var manager2FailV2Install = false + val manager2 = + createManager { _, _ -> + if (manager2FailV2Install) { + manager2FailV2Install = false + error("Simulated v2 install failure on manager 2") + } + dummyContext + } + + val res1 = manager1.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res1.isSuccess, "Manager 1 must load v1.jar successfully") + val res2 = manager2.installPlugin(v1Jar.absolutePath, enabled = true) + assertTrue(res2.isSuccess, "Manager 2 must load v1.jar successfully") + + val v2Jar = createDevTestJar(stagingRoot, pluginId, "v2000", "2.0.0") + manager2FailV2Install = true + + val reloadV2Result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(reloadV2Result.isFailure, "Reload with failing manager 2 must fail") + + assertRestoredToV1(manager1, pluginId, v1Jar) + assertRestoredToV1(manager2, pluginId, v1Jar) + + val v3Jar = createDevTestJar(stagingRoot, pluginId, "v3000", "3.0.0") + val resV3 = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(resV3.isSuccess, "Reload v3 must succeed") + + val v4Jar = createDevTestJar(stagingRoot, pluginId, "v4000", "4.0.0") + val resV4 = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(resV4.isSuccess, "Reload v4 must succeed") + + val v5Jar = createDevTestJar(stagingRoot, pluginId, "v5000", "5.0.0") + val resV5 = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(resV5.isSuccess, "Reload v5 must succeed") + + assertTrue(v3Jar.exists(), "v3 JAR must exist") + assertTrue(v4Jar.exists(), "v4 JAR must exist") + assertTrue(v5Jar.exists(), "v5 JAR must exist") + assertTrue( + v2Jar.exists(), + "Candidate v2 JAR from failed reload must be retained across subsequent successful reloads", + ) + } + + @Test + fun `preflight rejects reload of unloaded system plugin`() = + runBlocking { + val pluginId = "ai.rever.boss.system.core" + val stagingRoot = DevPluginArtifacts.stagingRoot() + createDevTestJar(stagingRoot, pluginId, "v1000", "1.0.0") + + val manager = createManager() + assertNull( + manager.getPluginInfo(pluginId), + "System plugin must not be loaded prior to reload", + ) + + val result = DevPluginReloader.reload(pluginId, stagingRoot) + assertTrue(result.isFailure, "Reload of unloaded protected system plugin must fail") + val message = result.exceptionOrNull()?.message.orEmpty() + assertTrue( + message.contains("protected system plugin"), + "Exception message must indicate protected system plugin: $message", + ) + } +} + +class IncompatibleBinaryPlugin : ai.rever.boss.plugin.api.Plugin { + override val pluginId = "com.example.binary.compat" + override val displayName = "Incompatible Binary Plugin" + + override fun register(context: PluginContext) = throw NoSuchMethodError("simulated binary incompatibility") +} + +class ControllableFailingPlugin : ai.rever.boss.plugin.api.Plugin { + override val pluginId = "com.example.failed.restore" + override val displayName = "Controllable Failing Plugin" + + override fun register(context: PluginContext) { + if (System.getProperty("boss.test.fail_rollback_binary") == "true") { + System.clearProperty("boss.test.fail_rollback_binary") + throw NoSuchMethodError("Simulated binary incompatible rollback") + } + if (System.getProperty("boss.test.fail_rollback_register") == "true") { + System.clearProperty("boss.test.fail_rollback_register") + error("Simulated rollback reinstall failure in register()") + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/PluginManifestTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/PluginManifestTest.kt new file mode 100644 index 0000000000..12577140ac --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/launchpad/PluginManifestTest.kt @@ -0,0 +1,432 @@ +package ai.rever.boss.plugin.launchpad + +import ai.rever.boss.components.plugin.DefaultPlugin +import ai.rever.boss.utils.ReloadResult +import ai.rever.boss.utils.SingleInstanceManager +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class PluginManifestTest { + @TempDir + lateinit var tempDir: Path + + @BeforeTest + fun setUp() { + val runtimeDir = File(tempDir.toFile(), "run") + SingleInstanceManager.runtimeDirOverride = runtimeDir + SingleInstanceManager.pluginReloadHandlerOverride = null + } + + @AfterTest + fun tearDown() { + SingleInstanceManager.release() + SingleInstanceManager.pluginReloadHandlerOverride = null + SingleInstanceManager.runtimeDirOverride = null + } + + @Test + fun `reload fails when the running host has no development reload handler`() { + assertTrue(SingleInstanceManager.acquireLock()) + + val result = SingleInstanceManager.reloadDevPlugin("sample-plugin") + + assertIs(result) + assertTrue(result.reason.contains("not available")) + } + + @Test + fun `HostMeta CURRENT_API_VERSION decouples from desktop app version and matches 1 dot x`() { + val apiVersion = HostMeta.CURRENT_API_VERSION + assertTrue( + apiVersion.matches(Regex("""^1\.\d+\.\d+.*""")), + "CURRENT_API_VERSION must adhere to plugin API baseline (1.x.x), got: $apiVersion", + ) + assertNotEquals("9.5.10", apiVersion, "CURRENT_API_VERSION must not be the desktop app version (9.5.10)") + } + + @Test + fun `SemVerValidator isCompatible evaluates true for compatible API versions`() { + val currentApi = HostMeta.CURRENT_API_VERSION + assertTrue( + SemVerValidator.isCompatible("1.0.51", currentApi), + "1.0.51 should be compatible with host API version $currentApi", + ) + assertTrue( + SemVerValidator.isCompatible("1.0.88", currentApi), + "1.0.88 should be compatible with host API version $currentApi", + ) + } + + @Test + fun `SemVerValidator isCompatible evaluates false for incompatible major version`() { + val currentApi = HostMeta.CURRENT_API_VERSION + assertFalse( + SemVerValidator.isCompatible("2.0.0", currentApi), + "Major version 2.0.0 must be incompatible with host API version $currentApi", + ) + } + + @Test + fun `pruneStagingHistory retains at most 3 latest version directories`() { + val pluginDevDir = Files.createDirectory(tempDir.resolve("sample-plugin")) + val timestamps = listOf(1000L, 2000L, 3000L, 4000L, 5000L) + + for (ts in timestamps) { + val vDir = pluginDevDir.resolve("v$ts") + Files.createDirectory(vDir) + Files.writeString(vDir.resolve("sample-plugin.jar"), "dummy jar content") + } + + assertEquals(5, Files.list(pluginDevDir).use { it.count() }) + + DevPluginArtifacts.pruneStagingHistory(pluginDevDir.toFile(), maxVersionsToKeep = 3) + + val remaining = + Files.list(pluginDevDir).use { stream -> + stream.map { it.fileName.toString() }.toList().sorted() + } + assertEquals(3, remaining.size, "Must keep at most 3 latest versions") + assertEquals(listOf("v3000", "v4000", "v5000"), remaining, "Must keep 3 newest timestamps") + } + + @Test + fun `pruning leaves staging in progress intact so its writer can finish`() { + val pluginDevDir = Files.createDirectory(tempDir.resolve("ignore-empty-test")) + val v1 = Files.createDirectory(pluginDevDir.resolve("v1000")) + Files.writeString(v1.resolve("ignore-empty-test.jar"), "valid jar 1") + val v2 = Files.createDirectory(pluginDevDir.resolve("v2000")) + Files.writeString(v2.resolve("ignore-empty-test.jar"), "valid jar 2") + + Files.createDirectory(pluginDevDir.resolve("v3000")) + val v4 = Files.createDirectory(pluginDevDir.resolve("v4000")) + Files.writeString(v4.resolve("ignore-empty-test.jar.part"), "incomplete") + val v5 = Files.createDirectory(pluginDevDir.resolve("v5000")) + Files.writeString(v5.resolve("ignore-empty-test.jar"), "") + + DevPluginArtifacts.pruneStagingHistory(pluginDevDir.toFile(), maxVersionsToKeep = 2) + + val remaining = + Files.list(pluginDevDir).use { stream -> + stream.map { it.fileName.toString() }.toList().sorted() + } + assertEquals(listOf("v1000", "v2000", "v3000", "v4000", "v5000"), remaining) + Files.move(v4.resolve("ignore-empty-test.jar.part"), v4.resolve("ignore-empty-test.jar")) + assertEquals("incomplete", Files.readString(v4.resolve("ignore-empty-test.jar"))) + } + + @Test + fun `SingleInstanceManager captures host-side exception during reload and returns structured RELOAD_FAILED`() { + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + throw OutOfMemoryError("Metaspace out of memory during plugin classloading") + } + + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + val result = SingleInstanceManager.reloadDevPlugin("sample-failing-tool") + assertIs(result) + assertTrue( + result.reason.contains("Metaspace out of memory"), + "Error response must preserve host-side exception message without socket drop: ${result.reason}", + ) + assertFalse( + result.reason.contains("Host closed connection unexpectedly"), + "Socket connection must remain open and return structured RELOAD_FAILED", + ) + } + + @Test + fun `findActiveDevJar resolves newest timestamp directory`() { + val devRoot = Files.createDirectory(tempDir.resolve("dev-root")) + val pluginDir = Files.createDirectory(devRoot.resolve("my-tool")) + val timestamps = listOf(1000L, 2000L, 5000L, 3000L) + + for (ts in timestamps) { + val vDir = Files.createDirectory(pluginDir.resolve("v$ts")) + Files.writeString(vDir.resolve("my-tool.jar"), "content-$ts") + } + + val activeJar = DevPluginArtifacts.findActiveDevJar("my-tool", devRoot.toFile()) + assertTrue(activeJar != null, "Active dev jar should be found") + assertTrue(activeJar.toString().contains("v5000"), "Active dev jar must be newest timestamp (v5000)") + + val allActive = DevPluginArtifacts.findAllActiveDevJars(devRoot.toFile()) + assertEquals(1, allActive.size) + assertEquals(activeJar, allActive.first()) + } + + @Test + fun `SingleInstanceManager captures nested exception cause and returns root cause detail`() { + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + val root = ClassNotFoundException("com.example.missing.RequiredDependency") + throw IllegalStateException("Failed to instantiate plugin class", root) + } + + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + val result = SingleInstanceManager.reloadDevPlugin("sample-nested-fail") + assertIs(result) + assertTrue( + result.reason.contains("ClassNotFoundException") && + result.reason.contains("RequiredDependency"), + "Error response must unwrap to root cause: ${result.reason}", + ) + } + + @Test + fun `SingleInstanceManager safely handles cyclic exception causes without hanging`() { + val cyclicError = RuntimeException("Outer cyclic error") + val innerError = RuntimeException("Inner cyclic error", cyclicError) + try { + val causeField = Throwable::class.java.getDeclaredField("cause") + causeField.isAccessible = true + causeField.set(cyclicError, innerError) + } catch (_: Exception) { + } + + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + throw cyclicError + } + + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + val result = SingleInstanceManager.reloadDevPlugin("sample-cyclic-fail") + assertIs(result) + assertTrue( + result.reason.contains("RuntimeException") && result.reason.contains("cyclic"), + "Error response must unwrap cyclic cause safely: ${result.reason}", + ) + } + + @Test + fun `SingleInstanceManager transmits non-ASCII UTF-8 characters cleanly without corruption`() { + val unicodeMessage = "Échec du chargement du module: 日本語エラー / 🚀" + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + throw IllegalStateException(unicodeMessage) + } + + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + val result = SingleInstanceManager.reloadDevPlugin("sample-unicode-tool") + assertIs(result) + assertTrue( + result.reason.contains("日本語エラー") && result.reason.contains("🚀"), + "Error response must preserve UTF-8 Unicode characters cleanly: ${result.reason}", + ) + } + + @Test + fun `DefaultPlugin deduplicateJars prefers version-rotated dev JAR over standard installed JAR`() { + val stagingBase = File(tempDir.toFile(), "staging-test") + stagingBase.mkdirs() + DevPluginArtifacts.stagingRootOverride = stagingBase + + try { + val pluginsDir = File(tempDir.toFile(), "plugins-dir") + pluginsDir.mkdirs() + + // 1. Standard installed JAR + val standardJar = File(pluginsDir, "my-plugin.jar") + writeSyntheticJar(standardJar, "my-plugin") + + // 2. Version-rotated dev JAR in staging root: my-plugin/v1000/my-plugin.jar + val versionDir = File(stagingBase, "my-plugin/v1000") + versionDir.mkdirs() + val versionJar = File(versionDir, "my-plugin.jar") + writeSyntheticJar(versionJar, "my-plugin") + + val discoveredDevJars = DefaultPlugin.findActiveDevJars(stagingBase) + assertEquals(1, discoveredDevJars.size) + assertEquals(versionJar.absolutePath, discoveredDevJars.single().absolutePath) + + // deduplicateJars must drop the standard JAR and retain the dev JAR candidate + val deduplicated = DefaultPlugin.deduplicateJars(listOf(standardJar, versionJar)) + assertEquals(1, deduplicated.size) + assertEquals(versionJar.absolutePath, deduplicated.single().absolutePath) + } finally { + DevPluginArtifacts.stagingRootOverride = null + } + } + + @Test + fun `DefaultPlugin deduplicateJars respects isProtectedPredicate`() { + val stagingBase = File(tempDir.toFile(), "protected-staging-root") + stagingBase.mkdirs() + DevPluginArtifacts.stagingRootOverride = stagingBase + + try { + val pluginsDir = File(tempDir.toFile(), "protected-plugins-dir") + pluginsDir.mkdirs() + + val standardJar = File(pluginsDir, "protected-plugin.jar") + writeSyntheticJar(standardJar, "protected-plugin") + standardJar.setLastModified(2000L) + + val versionDir = File(stagingBase, "protected-plugin/v1000") + versionDir.mkdirs() + val versionJar = File(versionDir, "protected-plugin.jar") + writeSyntheticJar(versionJar, "protected-plugin") + versionJar.setLastModified(5000L) + + // When isProtectedPredicate is true, dev JAR is penalized so standardJar wins even if older + val deduplicated = + DefaultPlugin.deduplicateJars(listOf(standardJar, versionJar)) { pluginId -> + pluginId == "protected-plugin" + } + assertEquals(1, deduplicated.size) + assertEquals(standardJar.absolutePath, deduplicated.single().absolutePath) + } finally { + DevPluginArtifacts.stagingRootOverride = null + } + } + + @Test + fun `DefaultPlugin deduplicateJars drops lone dev JAR claiming protected system plugin`() { + val stagingBase = File(tempDir.toFile(), "lone-protected-staging-root") + stagingBase.mkdirs() + DevPluginArtifacts.stagingRootOverride = stagingBase + + try { + val versionDir = File(stagingBase, "protected-system-plugin/v1000") + versionDir.mkdirs() + val versionJar = File(versionDir, "protected-system-plugin.jar") + writeSyntheticJar(versionJar, "protected-system-plugin") + versionJar.setLastModified(5000L) + + // A lone dev JAR claiming a protected plugin ID must be dropped immediately before grouping + val deduplicated = + DefaultPlugin.deduplicateJars(listOf(versionJar)) { pluginId -> + pluginId == "protected-system-plugin" + } + assertTrue(deduplicated.isEmpty(), "Lone dev JAR claiming protected plugin ID must be dropped") + } finally { + DevPluginArtifacts.stagingRootOverride = null + } + } + + @Test + fun `extractPluginIdFromManifestText reads pluginId with deps preceding it`() { + val manifestWithDepsFirst = + """ + { + "manifestVersion": 1, + "dependencies": [ + { + "id": "ai.rever.boss.gateway", + "version": "1.0.0" + } + ], + "pluginId": "com.example.actual-plugin" + } + """.trimIndent() + + val extracted = DevPluginArtifacts.extractPluginIdFromManifestText(manifestWithDepsFirst) + assertEquals("com.example.actual-plugin", extracted) + + val jarFile = File(tempDir.toFile(), "deps-first.jar") + jarFile.parentFile?.mkdirs() + java.util.jar.JarOutputStream(java.io.FileOutputStream(jarFile)).use { out -> + val entry = java.util.jar.JarEntry("META-INF/boss-plugin/plugin.json") + out.putNextEntry(entry) + out.write(manifestWithDepsFirst.toByteArray(java.nio.charset.StandardCharsets.UTF_8)) + out.closeEntry() + } + + assertTrue(DevPluginArtifacts.isValidDevJar(jarFile, "com.example.actual-plugin")) + assertFalse(DevPluginArtifacts.isValidDevJar(jarFile, "ai.rever.boss.gateway")) + assertEquals("com.example.actual-plugin", DefaultPlugin.extractPluginId(jarFile)) + } + + @Test + fun `DevPluginArtifacts isDevPluginJar avoids false positives on paths containing dev username`() { + val stagingBase = File(tempDir.toFile(), "staging-root") + stagingBase.mkdirs() + DevPluginArtifacts.stagingRootOverride = stagingBase + + try { + // Path inside staging root with version folder: TRUE + val validDevJar = File(stagingBase, "my-plugin/v1700000000/my-plugin.jar") + validDevJar.parentFile.mkdirs() + validDevJar.writeText("fake-jar") + assertTrue(DevPluginArtifacts.isDevPluginJar(validDevJar)) + + // Normal installed plugin path on a machine where username has 'dev': FALSE + val installedDirWithDevUser = File(tempDir.toFile(), "Users/dev_user/AppData/Local/plugins/my-plugin.jar") + installedDirWithDevUser.parentFile.mkdirs() + installedDirWithDevUser.writeText("fake-jar") + assertFalse(DevPluginArtifacts.isDevPluginJar(installedDirWithDevUser)) + + // Sibling directory named 'dev' outside staging root: FALSE + val outsideDevDir = File(tempDir.toFile(), "some/dev/folder/my-plugin.jar") + outsideDevDir.parentFile.mkdirs() + outsideDevDir.writeText("fake-jar") + assertFalse(DevPluginArtifacts.isDevPluginJar(outsideDevDir)) + } finally { + DevPluginArtifacts.stagingRootOverride = null + } + } + + @Test + fun `SingleInstanceManager reloadDevPlugin returns HostOffline on stale descriptor with dead port`() { + // Find an unused loopback TCP port by binding and immediately closing + val deadPort = java.net.ServerSocket(0).use { it.localPort } + + // Create a fake instance descriptor file pointing to this dead port + val runDir = File(tempDir.toFile(), "run") + runDir.mkdirs() + SingleInstanceManager.runtimeDirOverride = runDir + + val descriptorFile = File(runDir, "boss-instance.txt") + val token = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + descriptorFile.writeText("transport=TCP\nendpoint=$deadPort\ntoken=$token\n") + + val result = SingleInstanceManager.reloadDevPlugin("my-plugin", timeoutMs = 1000) + assertIs(result, "Should report HostOffline when port connection is refused") + } + + @Test + fun `reloadDevPlugin reports TimedOut when the host is alive but the reload does not confirm in time`() { + // The handler blocks past the client budget. The host still answers the + // probe ping, which is what separates "running and busy" from "offline". + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + Thread.sleep(4_000) + true + } + assertTrue(SingleInstanceManager.acquireLock(), "Acquire lock for IPC test") + + val result = SingleInstanceManager.reloadDevPlugin("slow-tool", timeoutMs = 1_000) + assertIs(result, "A live host that does not confirm is busy, not offline") + } + + @Test + fun `reloadDevPlugin refuses a path-shaped plugin id before touching the channel`() { + assertTrue(SingleInstanceManager.acquireLock()) + + val result = SingleInstanceManager.reloadDevPlugin("..") + assertIs(result) + assertTrue(result.reason.contains("Invalid plugin id"), "Got: ${result.reason}") + } + + private fun writeSyntheticJar( + targetFile: File, + pluginId: String, + ) { + targetFile.parentFile?.mkdirs() + java.util.jar.JarOutputStream(java.io.FileOutputStream(targetFile)).use { out -> + val entry = java.util.jar.JarEntry("META-INF/boss-plugin/plugin.json") + out.putNextEntry(entry) + val manifestJson = """{"id":"$pluginId","version":"1.0.0","displayName":"Test Plugin"}""" + out.write(manifestJson.toByteArray(java.nio.charset.StandardCharsets.UTF_8)) + out.closeEntry() + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/DevReloadResponseTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/DevReloadResponseTest.kt new file mode 100644 index 0000000000..31f625cdc1 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/DevReloadResponseTest.kt @@ -0,0 +1,70 @@ +package ai.rever.boss.utils + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.net.InetAddress +import java.net.ServerSocket +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import kotlin.test.assertTrue + +class DevReloadResponseTest { + @TempDir + lateinit var tempDir: Path + + @BeforeEach + fun useTempRuntimeDir() { + SingleInstanceManager.runtimeDirOverride = tempDir.resolve("run").toFile() + } + + @AfterEach + fun releaseChannel() { + SingleInstanceManager.release() + SingleInstanceManager.runtimeDirOverride = null + } + + @Test + fun `reload response warns when restoring the previous build also failed`() { + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + throw IllegalStateException("new build failed").apply { + addSuppressed(IllegalStateException("old build could not be restored")) + } + } + assertTrue(SingleInstanceManager.acquireLock()) + val result = SingleInstanceManager.reloadDevPlugin("diagnostic-plugin") + kotlin.test.assertIs(result) + assertTrue(result.reason.contains("Rollback failed"), result.reason) + assertTrue(result.reason.contains("new build failed"), result.reason) + } + + @Test + fun `only an exact plugin reload acknowledgment confirms success`() { + for (response in listOf("OK", "RELOAD_OK wrong-plugin", "RELOAD_OKevil", "RELOAD_OK test-plugin")) { + ServerSocket(0, 1, InetAddress.getLoopbackAddress()).use { server -> + val runtime = tempDir.resolve("run") + Files.createDirectories(runtime) + val descriptor = + InstanceDescriptor(SingleInstanceTransport.TCP, server.localPort.toString(), "a".repeat(64)) + Files.writeString(runtime.resolve("single-instance"), descriptor.encode()) + val reply = + CompletableFuture.runAsync { + server.accept().use { socket -> + socket.getInputStream().bufferedReader().readLine() + socket.getOutputStream().write((response + "\n").toByteArray()) + } + } + val result = SingleInstanceManager.reloadDevPlugin("test-plugin", timeoutMs = 1000) + reply.get(5, TimeUnit.SECONDS) + if (response == "RELOAD_OK test-plugin") { + kotlin.test.assertIs(result) + } else { + kotlin.test.assertIs(result) + } + } + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/SingleInstanceChannelTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/SingleInstanceChannelTest.kt index d1e7eaed5a..6d8ea9850f 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/SingleInstanceChannelTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/SingleInstanceChannelTest.kt @@ -604,6 +604,21 @@ class SingleInstanceChannelTest { assertFalse(called) } + @Test + fun `plugin dev reload refuses a path-shaped plugin id on the wire`() { + // The id joins straight onto the dev staging root on the host, so a + // traversal token must be refused before it is parsed as a request. + assertTrue(SingleInstanceManager.acquireLock()) + val descriptor = assertNotNull(readPublishedDescriptor()) + val base = "$PROTOCOL_VERSION ${descriptor.token} $VERB_PLUGIN_DEV_RELOAD " + assertEquals(RESPONSE_REJECTED, exchange(descriptor, "$base..")) + assertEquals(RESPONSE_REJECTED, exchange(descriptor, "$base..\\..\\etc")) + assertEquals(RESPONSE_REJECTED, exchange(descriptor, "${base}plugins/other")) + assertEquals(RESPONSE_REJECTED, exchange(descriptor, "${base}C:\\evil")) + assertEquals(RESPONSE_REJECTED, exchange(descriptor, base)) + assertEquals(RESPONSE_REJECTED, exchange(descriptor, base.dropLast(1))) + } + @Test fun `status JSON escapes platform strings`() { val previous = System.getProperty("os.arch") @@ -709,6 +724,22 @@ class SingleInstanceChannelTest { assertTrue(assertNotNull(failure).message.orEmpty().contains("response size limit")) } + @Test + fun `reloadDevPlugin transmits diagnostic error over 256 bytes`() { + val longDetail = "DiagnosticContextInfo_".repeat(16) + SingleInstanceManager.pluginReloadHandlerOverride = { _ -> + throw IllegalStateException(longDetail) + } + assertTrue(SingleInstanceManager.acquireLock()) + val result = SingleInstanceManager.reloadDevPlugin("diagnostic-plugin") + kotlin.test.assertIs(result) + assertTrue( + result.reason.length >= 350, + "Error response must preserve 350+ char diagnostic message: ${result.reason.length}", + ) + assertTrue(result.reason.contains("DiagnosticContextInfo_")) + } + // ==================== Helpers ==================== private fun runtimeDirPath(): Path = File(tempDir.toFile(), "run").toPath() diff --git a/docs/CLI.md b/docs/CLI.md index feb4340821..a26cef1ce0 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -26,6 +26,7 @@ You can install or update the CLI symlinks inside BossConsole via **Toolbox → | `boss status` | Checks running BossConsole health and status | `boss status --json` | | `boss doctor` | Reports health problems with suggested next steps (exit `2` when degraded) | `boss doctor --json` | | `boss mcp ` | Discovers and invokes MCP tools | `boss mcp list` | +| `boss plugin ` | Developer CLI: scaffold, validate, and link plugins | `boss plugin init my-tool` | | `boss completion ` | Generates shell tab-completion scripts | `boss completion bash > ~/.boss-complete.sh` | --- @@ -52,7 +53,7 @@ boss status --json ```json { "running": true, - "version": "9.5.7", + "version": "9.5.10", "os": "Windows 11", "arch": "amd64", "activeProject": "BossConsole", @@ -195,6 +196,33 @@ boss completion fish > ~/.config/fish/completions/boss.fish --- +## Plugin Developer CLI (`boss plugin`) + +The `boss plugin` command suite accelerates developing third-party plugins with scaffolding, validation, and hot-linking. See [`docs/PLUGIN_LAUNCHPAD.md`](PLUGIN_LAUNCHPAD.md) for full specifications. + +### 1. `boss plugin init ` +Scaffolds a new plugin project across templates (`mcp-tool`, `ui-panel`, `background-service`, `full`): +```bash +boss plugin init my-tool --template mcp-tool +boss plugin init my-service --template background-service --dir ~/plugins/my-service --json +``` + +### 2. `boss plugin validate []` +Validates a plugin source directory or packaged `.jar` against manifest rules, permitted permissions, and bytecode entrypoints: +```bash +boss plugin validate +boss plugin validate build/libs/my-plugin-0.1.0.jar --json +``` + +### 3. `boss plugin link []` +Links the plugin into `$BOSS_HOME/plugins/dev/`. If BossConsole is running, triggers a live hot-reload over the loopback IPC socket: +```bash +boss plugin link +boss plugin link . --json +``` + +--- + ## Process Exit Codes & Stream Guarantees The CLI adheres to strict UNIX process exit codes and standard stream separation: diff --git a/docs/PLUGIN_LAUNCHPAD.md b/docs/PLUGIN_LAUNCHPAD.md new file mode 100644 index 0000000000..c5e28c1b9b --- /dev/null +++ b/docs/PLUGIN_LAUNCHPAD.md @@ -0,0 +1,201 @@ +# BossConsole Plugin Launchpad & Developer CLI (`boss plugin`) + +The **Plugin Launchpad** provides developer tooling and a high-performance CLI suite for scaffolding, validating, and hot-reloading third-party plugins in BossConsole. + +--- + +## Command Reference + +### 1. `boss plugin init ` +Scaffolds a new, buildable plugin project with Gradle Kotlin Multiplatform and `boss-plugin-api`. + +```bash +# Basic MCP tool plugin scaffold +boss plugin init my-tools + +# Explicit template selection +boss plugin init my-service --template background-service + +# Custom output directory and overwrite flag +boss plugin init my-panel --template ui-panel --dir ~/projects/my-panel --force + +# Machine-readable JSON output +boss plugin init my-full-plugin --template full --json +``` + +#### Options & Flags +| Option | Short | Description | Default | +|---|---|---|---| +| `--template` | `-t` | Plugin template (`mcp-tool`, `ui-panel`, `background-service`, `full`) | `mcp-tool` | +| `--dir` | `-d` | Output directory path | `./` | +| `--force` | `-f` | Overwrite target directory if non-empty | `false` | +| `--json` | | Emit structured JSON output | `false` | + +```json +{ + "status": "scaffolded", + "pluginId": "my-tools", + "template": "mcp-tool", + "targetDir": "C:/Users/dev/my-tools", + "files": [ + "plugin.json", + "build.gradle.kts", + "settings.gradle.kts", + "gradlew", + "gradlew.bat", + "gradle/wrapper/gradle-wrapper.properties", + "gradle/wrapper/gradle-wrapper.jar", + "src/main/kotlin/com/example/mytools/MyToolsPlugin.kt", + "src/test/kotlin/com/example/mytools/MyToolsPluginTest.kt", + ".gitignore", + "README.md" + ] +} +``` + +--- + +### 2. `boss plugin validate []` +Validates a plugin source directory or packaged `.jar`/`.zip` archive against BossConsole canonical schemas, permissions, and bytecode integrity. + +```bash +# Validate current directory +boss plugin validate + +# Validate specific directory +boss plugin validate path/to/my-plugin + +# Validate packaged JAR archive +boss plugin validate build/libs/my-plugin-0.1.0.jar + +# Machine-readable JSON validation +boss plugin validate . --json +``` + +#### Verification Diagnostics +- **Directory Mode**: + - `target-exists`: Verifies directory exists. + - `manifest-exists`: Confirms presence of `plugin.json`. + - `manifest-json-valid`: Parses manifest schema without fatal exceptions. + - `id-format`: Enforces reverse domain notation (`^[a-zA-Z][a-zA-Z0-9_-]*(?:\.[a-zA-Z0-9_-]+)+$`). + - `version-format`: Validates SemVer conformance (`MAJOR.MINOR.PATCH`). + - `minApiVersion`: Asserts requirement $\le$ `HostMeta.CURRENT_API_VERSION` (`1.0.88`). + - `entrypoint-class`: Validates fully qualified class name syntax. + - `permissions`: Validates declared permissions against host RBAC permission identifier format. + - `mcp-tools`: Validates tool names against `^mcp__[a-zA-Z0-9_-]+__[a-zA-Z0-9_-]+$`. + - `host-manifest-contract`: Validates against host `PluginManifestReader.parseManifest` and `validateManifest`. +- **Archive Mode (`.jar` / `.zip`)**: + - Validates archive readability. + - Validates embedded `plugin.json`. + - **Bytecode Verification**: Ensures `mainClass.replace('.', '/') + ".class"` exists within archive bytecode entries and implements `Plugin`. + +#### Human-Readable Output Example +``` +Validating plugin at C:\Users\dev\my-tools... +[✓] target-exists: Target path exists: C:\Users\dev\my-tools +[✓] manifest-exists: plugin.json found +[✓] manifest-json-valid: plugin.json parsed successfully +[✓] id-format: Plugin ID 'com.example.my-tools' follows reverse domain notation +[✓] version-format: Plugin version '0.1.0' is valid SemVer +[✓] min-api-version: apiVersion '1.0.89' is compatible (host: 1.0.89) +[✓] entrypoint-class: Entrypoint class 'com.example.mytools.MyToolsPlugin' is a valid fully-qualified class name +[✓] permissions: Declared permissions list is empty (accessible to all authenticated users) +[✓] mcp-tools: All 1 MCP tool declarations are valid +[✓] host-manifest-contract: Manifest conforms to host PluginManifestReader contract + +[✓] Validation passed (10/10 checks passed) +``` + +--- + +### 3. `boss plugin link []` +Links or stages a local compiled plugin JAR into the BossConsole version-rotated development directory (`~/.boss/plugins/dev//v/.jar`). + +```bash +# Link local plugin project (must be built first with ./gradlew build) +boss plugin link . + +# Link specific directory +boss plugin link path/to/my-plugin + +# Machine-readable JSON output +boss plugin link . --json +``` + +#### Hot-Reload Lifecycle +1. **Artifact Resolution**: Resolves the compiled JAR from `build/libs/-*.jar`. Fails fast with an actionable error if the project has not been compiled (`./gradlew build` required before linking). +2. **Pre-flight Validation**: Automatically runs `PluginValidator.validate` against the target JAR archive before modifying any staging files. Invalid plugins fail fast with exit code `1`. +3. **Version-Rotated Staging**: Stages the JAR into: + `~/.boss/plugins/dev//v/.jar` + (or `~/.boss_debug/plugins/dev/...` in dev mode). + This prevents Windows file-locking collisions on running classloaders. The host keeps the latest 3 builds plus every build that may have acquired a classloader during this session, including failed reload candidates. Those extra paths remain until restart; a later successful reload can prune them. The offline CLI never prunes staging. +4. **Instance Detection & Hot-Reload**: + - **If BossConsole is running**: Dispatches ` PLUGIN_DEV_RELOAD \n` over loopback socket and awaits synchronous host acknowledgment (`RELOAD_OK`). Any host-side exceptions are captured and returned as `RELOAD_FAILED ` without dropping the socket. + - **If BossConsole is offline**: Stages the plugin cleanly into the version-rotated dev folder and reports ready for next launch (`status: "staged", running: false`). + +The API, Toolbox, terminal, browser and editor system plugins cannot be overridden through dev staging. Live reload refuses protected identities and plugins owning native resources; their normal installation/update workflow and an application restart remain necessary. A plugin that exists only in dev staging has no installed record, so disabling it is not persisted across restart. Remove its staging directory while BOSS is stopped to remove that dev override. + +A failed newest dev build falls back to the installed store build on startup, not to an older dev version. Rebuild and link a corrected version, or remove the failed staging directory while BOSS is stopped. A link during host startup can report failure before any plugin manager is ready; the staged build remains on disk. In-progress staging directories are left to their writer rather than pruned by another reload. + +> **Note on Startup Precedence**: At application launch, BossConsole prioritizes staged development JARs in `~/.boss/plugins/dev//` over installed store versions for that same plugin ID via pre-load resolution (`resolvePersistedEntryPath` and `deduplicateJars`). Since `boss plugin link` pre-validates JARs before staging, dev artifacts are well-formed; if a dev build is missing or corrupt, startup automatically falls back to the installed store build. Protected system plugins and plugins requiring application restart (`HotReloadPolicy`) are never overridden by development JARs. + +--- + +## Project Templates + +| Template | Primary Use Case | Default Permissions | MCP Tools | +|---|---|---|---| +| `mcp-tool` | Exposing MCP tools to AI agents | `[]` (Open to authenticated users) | 1 sample action tool | +| `ui-panel` | Custom Compose Desktop UI panels & tabs | `[]` (Open to authenticated users) | None | +| `background-service` | Autonomous background workers / daemons | `[]` (Open to authenticated users) | None | +| `full` | Enterprise plugins combining UI, MCP, and CLI | `[]` (Open to authenticated users) | 1 action tool | + +--- + +## Canonical Schemas + +### `plugin.json` Schema +```json +{ + "pluginId": "com.example.sample-plugin", + "displayName": "Sample Plugin", + "version": "0.1.0", + "description": "BossConsole plugin for sample-plugin", + "author": "Boss Developer", + "apiVersion": "1.0.88", + "mainClass": "com.example.sampleplugin.SamplePluginPlugin", + "requiredPermissions": [], + "mcpTools": [ + { + "name": "mcp__com_example_sample_plugin__action", + "description": "Executes sample-plugin action tool", + "adminOnly": false + } + ] +} +``` + +### Permission Model & RBAC +`requiredPermissions` declares the host RBAC permissions (e.g. `plugins.create`, `secret.read`, `api_key.create`) required to access and run the plugin. +- An empty list (`[]`) means the plugin is accessible to all authenticated users. +- Scaffolded starter templates emit `[]` by default so any non-admin developer can build, test, and link without hitting RBAC permission gating (`pluginAccessAllowed`). +- When non-empty, permissions must follow the standard dot/dash/underscore RBAC identifier format (`^[a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*$`). + +--- + +## Typical Developer Workflow + +```bash +# 1. Initialize project +boss plugin init code-helper --template mcp-tool +cd code-helper + +# 2. Build plugin JAR (Required before linking) +./gradlew build + +# 3. Validate project structure and compiled JAR +boss plugin validate . + +# 4. Link & live-reload into running BossConsole +boss plugin link . +``` diff --git a/scripts/boss b/scripts/boss index 8bccfe4080..7ea5cc0508 100755 --- a/scripts/boss +++ b/scripts/boss @@ -108,6 +108,37 @@ detect_and_route() { exit 1 } +forward_to_boss() { + BOSS_BIN="${BOSS_BIN:-}" + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" + if [ -z "$BOSS_BIN" ]; then + if [[ "$OSTYPE" == "darwin"* ]]; then + if [ -x "/Applications/BOSS.app/Contents/MacOS/BOSS" ]; then + BOSS_BIN="/Applications/BOSS.app/Contents/MacOS/BOSS" + elif [ -x "$HOME/Applications/BOSS.app/Contents/MacOS/BOSS" ]; then + BOSS_BIN="$HOME/Applications/BOSS.app/Contents/MacOS/BOSS" + elif [ -x "$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/BOSS.app/Contents/MacOS/BOSS" ]; then + BOSS_BIN="$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/BOSS.app/Contents/MacOS/BOSS" + fi + elif [[ "$OSTYPE" == "linux"* ]]; then + if command -v boss-desktop &> /dev/null; then + BOSS_BIN="boss-desktop" + elif [ -x "/opt/boss/bin/BOSS" ]; then + BOSS_BIN="/opt/boss/bin/BOSS" + elif [ -x "$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/bin/BOSS" ]; then + BOSS_BIN="$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/bin/BOSS" + fi + fi + fi + + if [ -n "$BOSS_BIN" ] && command -v "$BOSS_BIN" >/dev/null 2>&1; then + exec "$BOSS_BIN" "$@" + else + echo "Error: BOSS application binary not found. Set BOSS_BIN to the packaged executable." >&2 + exit 1 + fi +} + # Main command handling case "$1" in url) @@ -176,45 +207,25 @@ case "$1" in ;; plugin) - if [ -z "$2" ]; then - echo "Error: Plugin ID required" - echo "Usage: boss plugin " - exit 1 - fi - encoded_id=$(urlencode "$2") - deep_link="boss://plugin?id=${encoded_id}" - open_url "$deep_link" + subcommand="${2:-}" + case "$subcommand" in + init|validate|link|--help|-h|help|"") + forward_to_boss "$@" + ;; + *) + if [ $# -le 2 ]; then + encoded_id=$(urlencode "$2") + deep_link="boss://plugin?id=${encoded_id}" + open_url "$deep_link" + else + forward_to_boss "$@" + fi + ;; + esac ;; status|doctor|mcp|completion) - BOSS_BIN="${BOSS_BIN:-}" - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" - if [ -z "$BOSS_BIN" ]; then - if [[ "$OSTYPE" == "darwin"* ]]; then - if [ -x "/Applications/BOSS.app/Contents/MacOS/BOSS" ]; then - BOSS_BIN="/Applications/BOSS.app/Contents/MacOS/BOSS" - elif [ -x "$HOME/Applications/BOSS.app/Contents/MacOS/BOSS" ]; then - BOSS_BIN="$HOME/Applications/BOSS.app/Contents/MacOS/BOSS" - elif [ -x "$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/BOSS.app/Contents/MacOS/BOSS" ]; then - BOSS_BIN="$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/BOSS.app/Contents/MacOS/BOSS" - fi - elif [[ "$OSTYPE" == "linux"* ]]; then - if command -v boss-desktop &> /dev/null; then - BOSS_BIN="boss-desktop" - elif [ -x "/opt/boss/bin/BOSS" ]; then - BOSS_BIN="/opt/boss/bin/BOSS" - elif [ -x "$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/bin/BOSS" ]; then - BOSS_BIN="$SCRIPT_DIR/../composeApp/build/compose/binaries/main/app/bin/BOSS" - fi - fi - fi - - if [ -n "$BOSS_BIN" ] && command -v "$BOSS_BIN" >/dev/null 2>&1; then - exec "$BOSS_BIN" "$@" - else - echo "Error: BOSS application binary not found. Set BOSS_BIN to the packaged executable." >&2 - exit 1 - fi + forward_to_boss "$@" ;; --version|-v|version) diff --git a/scripts/boss.bat b/scripts/boss.bat index 4779cffe7a..34ecf66792 100644 --- a/scripts/boss.bat +++ b/scripts/boss.bat @@ -106,11 +106,15 @@ start "" "boss://terminal?command=%ENCODED%" goto :eof :cmd_plugin -if "%~2"=="" ( - echo Error: Plugin ID required - echo Usage: boss plugin ^ - exit /b 1 -) +if "%~2"=="" goto :cmd_forward_exe +if /i "%~2"=="init" goto :cmd_forward_exe +if /i "%~2"=="validate" goto :cmd_forward_exe +if /i "%~2"=="link" goto :cmd_forward_exe +if /i "%~2"=="--help" goto :cmd_forward_exe +if /i "%~2"=="-h" goto :cmd_forward_exe +if /i "%~2"=="help" goto :cmd_forward_exe +if not "%~3"=="" goto :cmd_forward_exe + call :urlencode "%~2" ENCODED start "" "boss://plugin?id=%ENCODED%" goto :eof @@ -119,12 +123,23 @@ goto :eof REM Preserve literal exclamation marks in JSON arguments. setlocal DisableDelayedExpansion if defined BOSS_EXE if not exist "%BOSS_EXE%" goto :cmd_missing_exe -if not defined BOSS_EXE set "BOSS_EXE=%LOCALAPPDATA%\Programs\BOSS\BOSS.exe" -if not exist "%BOSS_EXE%" set "BOSS_EXE=%ProgramFiles%\BOSS\BOSS.exe" -if not exist "%BOSS_EXE%" set "BOSS_EXE=%~dp0..\composeApp\build\compose\binaries\main\app\BOSS\BOSS.exe" -if not exist "%BOSS_EXE%" goto :cmd_missing_exe +if not defined BOSS_EXE if exist "%LOCALAPPDATA%\Programs\BOSS\BOSS.exe" set "BOSS_EXE=%LOCALAPPDATA%\Programs\BOSS\BOSS.exe" +if not defined BOSS_EXE if exist "%ProgramFiles%\BOSS\BOSS.exe" set "BOSS_EXE=%ProgramFiles%\BOSS\BOSS.exe" +if not defined BOSS_EXE if exist "%~dp0..\composeApp\build\compose\binaries\main\app\BOSS\BOSS.exe" set "BOSS_EXE=%~dp0..\composeApp\build\compose\binaries\main\app\BOSS\BOSS.exe" +if not defined BOSS_EXE goto :cmd_missing_exe +if exist "%~dp0boss.ps1" goto :cmd_forward_ps "%BOSS_EXE%" %* exit /b %ERRORLEVEL% +goto :eof + +:cmd_forward_ps +REM The exit code rides out on its own line, parsed only after powershell +REM returns, so %ERRORLEVEL% still holds the launcher's result. Inside a +REM parenthesized block the whole block parses first, where %ERRORLEVEL% is +REM stale and !ERRORLEVEL! stays literal because delayed expansion is off. +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0boss.ps1" %* +exit /b %ERRORLEVEL% +goto :eof :cmd_missing_exe >&2 echo Error: BOSS application binary not found. Set BOSS_EXE to the packaged executable. diff --git a/scripts/boss.ps1 b/scripts/boss.ps1 index 01288737a2..3773556115 100644 --- a/scripts/boss.ps1 +++ b/scripts/boss.ps1 @@ -228,14 +228,37 @@ switch ($Command.ToLower()) { } "plugin" { - if ([string]::IsNullOrEmpty($Argument)) { - Write-Error "Error: Plugin ID required" - Write-Host "Usage: boss.ps1 plugin " + $subcommands = @("init", "validate", "link", "--help", "-h", "help") + if (-not [string]::IsNullOrEmpty($Argument) -and $Argument.ToLower() -notin $subcommands -and $args.Count -le 2) { + $encoded = [System.Uri]::EscapeDataString($Argument) + $deepLink = "boss://plugin?id=$encoded" + Open-BossDeepLink $deepLink + } else { + $bossExe = $env:BOSS_EXE + if ($bossExe -and -not (Test-Path $bossExe -PathType Leaf)) { + [Console]::Error.WriteLine("Error: BOSS_EXE does not name an executable file.") + exit 1 + } + if (-not $bossExe -or -not (Test-Path $bossExe)) { + $bossExe = "$env:LOCALAPPDATA\Programs\BOSS\BOSS.exe" + } + if (-not (Test-Path $bossExe)) { + $bossExe = "$env:ProgramFiles\BOSS\BOSS.exe" + } + if (-not (Test-Path $bossExe)) { + $bossExe = "$PSScriptRoot\..\composeApp\build\compose\binaries\main\app\BOSS\BOSS.exe" + } + if (Test-Path $bossExe) { + $forwardArgs = @($args) + if ($PSVersionTable.PSVersion -ge [Version]"7.3") { + $PSNativeCommandArgumentPassing = 'Standard' + } + & $bossExe @forwardArgs | Out-Host + exit $LASTEXITCODE + } + [Console]::Error.WriteLine("Error: BOSS application binary not found. Set BOSS_EXE to the packaged executable.") exit 1 } - $encoded = [System.Uri]::EscapeDataString($Argument) - $deepLink = "boss://plugin?id=$encoded" - Open-BossDeepLink $deepLink } { $_ -in "status", "doctor", "mcp", "completion" } { @@ -258,7 +281,7 @@ switch ($Command.ToLower()) { if ($PSVersionTable.PSVersion -ge [Version]"7.3") { $PSNativeCommandArgumentPassing = 'Standard' } - & $bossExe @forwardArgs + & $bossExe @forwardArgs | Out-Host exit $LASTEXITCODE } [Console]::Error.WriteLine("Error: BOSS application binary not found. Set BOSS_EXE to the packaged executable.") diff --git a/scripts/test/test-headless-cli.sh b/scripts/test/test-headless-cli.sh old mode 100644 new mode 100755 From abb8686c3be414a3c161fd93db3b73ec673721db Mon Sep 17 00:00:00 2001 From: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:14:30 +0530 Subject: [PATCH 44/49] feat(import): Bitwarden JSON and KeePass XML password importers (#679) * feat(import): Bitwarden JSON and KeePass XML password importers The CSV path already reads Bitwarden/1Password/LastPass/Chrome/Safari exports via the header aliases. This adds the two common export formats it cannot read: - BitwardenJsonParser: the unencrypted Bitwarden JSON export (the default Export vault offers). Reads login items only, resolves the website from the first URI or the item name, and returns nothing for an encrypted export rather than ciphertext masquerading as credentials. - KeePassXmlParser: the KeePass 2.x plaintext XML export. Gets two format traps right: an entry's is not imported as extra credentials, and Recycle Bin entries are skipped. Parses untrusted XML with DTDs and external entities disabled (XXE hardening). Both are wired into ImportFileReader's content sniffing, which now routes KeePass XML and Bitwarden JSON ahead of the HTML/CSV paths on their unambiguous markers. Both use the low-level JSON/DOM APIs the host already has, so there are no new dependencies and no serializable models to drift. Neither logs a value. * fix(import): read Bitwarden passwords verbatim, wire json/xml picker, anchor KeePass sniff Addresses the review on #679: 1. BitwardenJsonParser read the password through the trimming string() helper, so " pa ss " lost its edge whitespace and an all-whitespace password was dropped as if absent. The password is now read verbatim; labels stay trimmed. 2. The import file picker only offered csv/html/htm, so a Bitwarden .json or KeePass .xml export could not be selected. It now offers json and xml too. 3. looksLikeKeePass searched the whole text, so a CSV whose field merely contained " --- .../components/dialogs/ImportDataDialog.kt | 3 +- .../dialogs/ImportDataDialogContent.kt | 2 +- .../services/importer/BitwardenJsonParser.kt | 103 +++++++++++ .../services/importer/ImportFileReader.kt | 54 +++++- .../boss/services/importer/ImportModels.kt | 4 + .../boss/services/importer/ImportService.kt | 12 +- .../services/importer/KeePassXmlParser.kt | 166 ++++++++++++++++++ .../importer/BitwardenJsonParserTest.kt | 84 +++++++++ .../importer/ImportFileReaderFormatTest.kt | 144 +++++++++++++++ .../services/importer/KeePassXmlParserTest.kt | 132 ++++++++++++++ 10 files changed, 690 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/BitwardenJsonParser.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/KeePassXmlParser.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/BitwardenJsonParserTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/ImportFileReaderFormatTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/KeePassXmlParserTest.kt diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialog.kt index cceeee435b..4f25fbd1c5 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialog.kt @@ -227,7 +227,8 @@ private fun rememberImportPicker( } } }, - fileExtensions = listOf("csv", "html", "htm"), + // csv/html for browsers and CSV managers; json for a Bitwarden export; xml for a KeePass export. + fileExtensions = listOf("csv", "html", "htm", "json", "xml"), title = "Choose a passwords or bookmarks export", ) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialogContent.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialogContent.kt index 214ee54e3d..a2f1e7d2f1 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialogContent.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ImportDataDialogContent.kt @@ -79,7 +79,7 @@ internal fun ChooseSourceContent( Spacer(Modifier.height(BossTheme.space.lg)) SectionLabel("FROM A FILE") Text( - "Passwords from CSV, bookmarks from HTML - Chrome ⋮ ▸ Passwords ▸ Export, " + + "Passwords from CSV, Bitwarden JSON or KeePass XML; bookmarks from HTML - Chrome ⋮ ▸ Passwords ▸ Export, " + "Safari File ▸ Export, Firefox Passwords ▸ Export Logins.", style = BossTheme.type.body, color = BossTheme.colors.textSecondary, diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/BitwardenJsonParser.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/BitwardenJsonParser.kt new file mode 100644 index 0000000000..ed8e0dd2b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/BitwardenJsonParser.kt @@ -0,0 +1,103 @@ +package ai.rever.boss.services.importer + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull + +/** + * Reads a Bitwarden unencrypted JSON export (`Tools > Export vault`, "json"). + * + * The CSV path already covers Bitwarden's CSV export via the header aliases; this + * covers the JSON export, which is the default Bitwarden offers and which the CSV + * path cannot read. The shape: + * + * ```json + * { "encrypted": false, "items": [ + * { "type": 1, "name": "Example", "notes": "…", + * "login": { "uris": [ {"uri": "https://example.com"} ], + * "username": "john", "password": "hunter2" } } + * ]} + * ``` + * + * Only login items (`type == 1`) carry a credential; secure notes, cards and + * identities are skipped. Uses the low-level JSON element API - the same the + * Chromium reader uses - so there are no `@Serializable` models to drift, and + * unknown fields are ignored by construction. + * + * Handles plaintext passwords, so the logging rule is absolute: this parses and + * returns, and never logs a value. + */ +object BitwardenJsonParser { + private val json = Json { ignoreUnknownKeys = true } + + /** Bitwarden's item type discriminator for a login. */ + private const val TYPE_LOGIN = 1 + + /** + * Every login in [text] as an [ImportedPassword], or an empty list when the + * text is not a Bitwarden export. + * + * An encrypted export (`"encrypted": true`) has no readable passwords, so it + * returns empty rather than a list of ciphertext masquerading as credentials. + */ + fun parse(text: String): List = parseEntries(text).filter { it.password.isNotEmpty() } + + /** Includes incomplete logins so the preview can explain why they are skipped. */ + internal fun parseEntries(text: String): List { + val root = runCatching { json.parseToJsonElement(text) as? JsonObject }.getOrNull() + // An encrypted export (`"encrypted": true`) has no readable passwords. + val items = + root + ?.takeIf { it["encrypted"] == null || (it["encrypted"] as? JsonPrimitive)?.contentOrNull == "false" } + ?.get("items") as? JsonArray + ?: return emptyList() + return items.mapNotNull { element -> (element as? JsonObject)?.let(::loginOf) } + } + + /** True when [text] parses as a JSON object with an `items` array - a cheap sniff for routing. */ + fun looksLikeBitwarden(text: String): Boolean { + val root = runCatching { json.parseToJsonElement(text) as? JsonObject }.getOrNull() ?: return false + return root["items"] is JsonArray + } + + /** One login item to a credential, including missing fields for preview validation. */ + private fun loginOf(item: JsonObject): ImportedPassword? { + val isLogin = (item["type"] as? JsonPrimitive)?.intOrNull == TYPE_LOGIN + val login = item["login"] as? JsonObject + // Read the password verbatim: edge whitespace is part of a password, not + // formatting, so it must not be trimmed away (and an all-spaces password + // must not be dropped as if it were absent). + val password = login?.rawString("password").orEmpty() + if (!isLogin || login == null) return null + + val website = firstUri(login) ?: item.string("name").orEmpty() + return ImportedPassword( + website = website, + username = login.string("username").orEmpty(), + password = password, + notes = item.string("notes"), + ) + } + + /** Prefer a web URI to native-app associations, keeping an app-only URI for the skipped preview. */ + private fun firstUri(login: JsonObject): String? { + val uris = login["uris"] as? JsonArray ?: return null + return uris + .asSequence() + .mapNotNull { (it as? JsonObject)?.string("uri") } + .toList() + .let { values -> values.firstOrNull { !isNonWebPasswordEntry(it) } ?: values.firstOrNull() } + } + + /** A string field, trimmed, or null when absent, JSON null, or blank. For labels (website/username/notes). */ + private fun JsonObject.string(key: String): String? = rawString(key)?.trim()?.ifEmpty { null } + + /** Only JSON strings are credentials; objects and numeric primitives are not passwords. */ + private fun JsonObject.rawString(key: String): String? { + val value = this[key] as? JsonPrimitive + return value?.takeIf { it.isString }?.contentOrNull + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportFileReader.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportFileReader.kt index d2a4c69624..443324a892 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportFileReader.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportFileReader.kt @@ -57,7 +57,15 @@ object ImportFileReader { path.endsWith(".html", ignoreCase = true) || path.endsWith(".htm", ignoreCase = true) - if (looksLikeHtml) parseBookmarkHtml(text) else parsePasswordCsv(text) + // Password-manager formats are sniffed before HTML/CSV: their markers + // are unambiguous, so a KeePass export saved with an odd extension is + // still read correctly. + when { + KeePassXmlParser.looksLikeKeePass(text) -> parseKeePassXml(text) + BitwardenJsonParser.looksLikeBitwarden(text) -> parseBitwardenJson(text) + looksLikeHtml -> parseBookmarkHtml(text) + else -> parsePasswordCsv(text) + } }.onFailure { error -> logger.warn( LogCategory.FILE, @@ -77,6 +85,48 @@ object ImportFileReader { return ImportPreview(bookmarks = bookmarks) } + private fun parseBitwardenJson(text: String): ImportPreview { + val passwords = BitwardenJsonParser.parseEntries(text) + if (passwords.isEmpty()) { + throw UnrecognisedImportFileException( + "No logins found in that Bitwarden export. Export the vault unencrypted and try again.", + ) + } + logger.info(LogCategory.AUTH, "Parsed Bitwarden export", mapOf("count" to passwords.size)) + return passwordManagerPreview(passwords) + } + + private fun parseKeePassXml(text: String): ImportPreview { + val passwords = KeePassXmlParser.parseEntries(text) + if (passwords.isEmpty()) { + throw UnrecognisedImportFileException( + "No entries with a password found in that KeePass export.", + ) + } + logger.info(LogCategory.AUTH, "Parsed KeePass export", mapOf("count" to passwords.size)) + return passwordManagerPreview(passwords) + } + + private fun passwordManagerPreview(entries: List): ImportPreview { + val passwords = mutableListOf() + val skipped = mutableListOf() + entries.forEachIndexed { index, entry -> + val reason = + when { + entry.website.isBlank() || isNonWebPasswordEntry(entry.website) -> SkipReason.MISSING_URL + entry.username.isBlank() -> SkipReason.MISSING_USERNAME + entry.password.isBlank() -> SkipReason.MISSING_PASSWORD + else -> null + } + if (reason == null) { + passwords.add(entry) + } else { + skipped.add(SkippedRow(index + 1, reason, displayLabel(entry.website, entry.username))) + } + } + return ImportPreview(passwords = passwords, skipped = skipped) + } + private fun parsePasswordCsv(text: String): ImportPreview { val table = CsvParser.parseTable(text) val columns = table?.let { resolvePasswordColumns(it.header) } @@ -173,7 +223,7 @@ object ImportFileReader { row.size <= maxOf(columns.url, columns.password) -> SkipReason.MALFORMED_ROW url.isEmpty() -> SkipReason.MISSING_URL username.isEmpty() -> SkipReason.MISSING_USERNAME - password.isEmpty() -> SkipReason.MISSING_PASSWORD + password.isBlank() -> SkipReason.MISSING_PASSWORD else -> null } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportModels.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportModels.kt index 3609832a40..5bffb3cdd4 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportModels.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportModels.kt @@ -99,3 +99,7 @@ internal fun displayLabel( website: String, username: String, ): String = if (username.isEmpty()) website else "$username @ $website" + +/** Native-app associations must never become browser website matches. */ +internal fun isNonWebPasswordEntry(raw: String): Boolean = + listOf("android://", "androidapp://", "iosapp://").any { raw.trim().startsWith(it, ignoreCase = true) } diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportService.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportService.kt index 0500d6dd9d..5e6544fa07 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportService.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/ImportService.kt @@ -68,7 +68,7 @@ object ImportService { val blocked = when { - website.isEmpty() || isNonWebEntry(entry.website) -> SkipReason.MISSING_URL + website.isEmpty() || isNonWebPasswordEntry(entry.website) -> SkipReason.MISSING_URL // Chrome stores logins with an empty username_value. // The CSV path pre-skips these; without the same check @@ -76,7 +76,7 @@ object ImportService { // them, and they render as red failures. entry.username.isBlank() -> SkipReason.MISSING_USERNAME - entry.password.isEmpty() -> SkipReason.MISSING_PASSWORD + entry.password.isBlank() -> SkipReason.MISSING_PASSWORD key in existing -> SkipReason.ALREADY_EXISTS @@ -178,14 +178,6 @@ object ImportService { return host?.lowercase()?.removePrefix("www.") ?: trimmed } - /** - * Entries a browser stores for native apps rather than web pages. - * - * Chrome exports rows like `android://@com.example`; they have no host - * and could never be autofilled, so importing them adds noise only. - */ - private fun isNonWebEntry(raw: String): Boolean = raw.trim().startsWith("android://", ignoreCase = true) - // ==================== Bookmarks ==================== /** diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/KeePassXmlParser.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/KeePassXmlParser.kt new file mode 100644 index 0000000000..59231b42bc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/services/importer/KeePassXmlParser.kt @@ -0,0 +1,166 @@ +package ai.rever.boss.services.importer + +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.xml.sax.InputSource +import org.xml.sax.SAXParseException +import org.xml.sax.helpers.DefaultHandler +import java.io.StringReader +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Reads a KeePass 2.x plaintext XML export (`File > Export > KeePass XML (2.x)`). + * + * The `.kdbx` database itself is encrypted binary and needs the user's master + * key, so it is out of scope; the XML export writes the entries in the clear, + * which is what a migrating user produces. The shape: + * + * ```xml + * + * + * TitleExample + * UserNamejohn + * Passwordhunter2 + * URLhttps://example.com + * + * … nested groups … + * + * ``` + * + * Two format traps this gets right: + * + * - **History is not descended into.** An `` can hold a `` of its + * own past versions, each a full ``; a naive "every Entry" walk would + * import a credential once per revision. This reads only an entry's direct + * `` children and never recurses into an entry. + * - **The Recycle Bin is skipped.** Deleted entries live in a group whose UUID + * the `` block names; importing them would resurrect passwords the user + * threw away. + * + * Parses untrusted XML, so external entities and DTDs are disabled (XXE). Handles + * plaintext passwords: this parses and returns, and never logs a value. + */ +object KeePassXmlParser { + /** UUID KeePass writes when the Recycle Bin is unset (16 zero bytes, Base64). */ + private const val EMPTY_UUID = "AAAAAAAAAAAAAAAAAAAAAA==" + + /** + * Every credential in [text], or an empty list when it is not a KeePass XML + * export or cannot be parsed. + */ + fun parse(text: String): List = parseEntries(text).filter { it.password.isNotEmpty() } + + /** Includes incomplete entries for the preview, but never history or deleted entries. */ + internal fun parseEntries(text: String): List { + val doc = runCatching { secureDocument(text) }.getOrNull() + val root = + doc?.documentElement?.takeIf { it.tagName == "KeePassFile" } + ?: return emptyList() + + val out = mutableListOf() + childElement(root, "Root")?.let { collectFrom(it, recycleBinUuid(root), out) } + return out + } + + /** + * A cheap sniff for routing: the document's ROOT element is ``. + * + * Anchored at the start (past an optional BOM, XML declaration and comments) + * rather than searching the whole text - otherwise a CSV or bookmark file + * whose data merely contains the string `]*\?>\s*)?(?:\s*)*)""", + setOf(RegexOption.DOT_MATCHES_ALL), + ) + + /** Walk [node]'s child groups and entries, skipping the Recycle Bin group. */ + private fun collectFrom( + node: Element, + recycleBin: String?, + out: MutableList, + ) { + for (child in childElements(node)) { + when (child.tagName) { + "Group" -> { + val uuid = childElement(child, "UUID")?.textContent?.trim() + // Skip deleted entries; descend into every other group. + if (uuid == null || uuid != recycleBin) collectFrom(child, recycleBin, out) + } + + "Entry" -> { + entryToPassword(child)?.let(out::add) + } + } + } + } + + /** One entry's `` fields to a credential, or null when it has no password. */ + private fun entryToPassword(entry: Element): ImportedPassword? { + val fields = HashMap() + for (field in childElements(entry)) { + // Only direct String children: this deliberately does not enter . + if (field.tagName == "String") { + val key = childElement(field, "Key")?.textContent?.trim() + if (!key.isNullOrEmpty()) { + fields[key] = childElement(field, "Value")?.textContent ?: "" + } + } + } + + // Edge whitespace is part of a password, not formatting, so it is not trimmed. + val password = fields["Password"].orEmpty() + + val url = fields["URL"]?.trim().orEmpty() + val website = url.ifEmpty { fields["Title"]?.trim().orEmpty() } + return ImportedPassword( + website = website, + username = fields["UserName"]?.trim().orEmpty(), + password = password, + notes = fields["Notes"]?.trim()?.ifEmpty { null }, + ) + } + + /** The Recycle Bin group's UUID when set, including a disabled bin with old entries, else null. */ + private fun recycleBinUuid(root: Element): String? { + val meta = childElement(root, "Meta") ?: return null + val uuid = childElement(meta, "RecycleBinUUID")?.textContent?.trim() + return uuid?.takeIf { it.isNotEmpty() && it != EMPTY_UUID } + } + + /** Parse [text] with external entities and DTDs disabled (XXE hardening). */ + private fun secureDocument(text: String): Document { + val factory = DocumentBuilderFactory.newInstance() + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + factory.setFeature("http://xml.org/sax/features/external-general-entities", false) + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false) + factory.isXIncludeAware = false + factory.isExpandEntityReferences = false + val builder = factory.newDocumentBuilder() + // The default handler writes untrusted XML fragments to stderr on parse errors. + builder.setErrorHandler( + object : DefaultHandler() { + override fun error(error: SAXParseException): Unit = throw error + + override fun fatalError(error: SAXParseException): Unit = throw error + }, + ) + return builder.parse(InputSource(StringReader(text.removePrefix("\uFEFF")))) + } + + private fun childElements(node: Element): List { + val children = node.childNodes + return (0 until children.length).mapNotNull { children.item(it) as? Element } + } + + private fun childElement( + node: Element, + name: String, + ): Element? = childElements(node).firstOrNull { it.tagName == name } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/BitwardenJsonParserTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/BitwardenJsonParserTest.kt new file mode 100644 index 0000000000..763e6ef344 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/BitwardenJsonParserTest.kt @@ -0,0 +1,84 @@ +package ai.rever.boss.services.importer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Pins [BitwardenJsonParser] against the shape of an unencrypted Bitwarden JSON export. */ +class BitwardenJsonParserTest { + private val export = + """ + { + "encrypted": false, + "folders": [ { "id": "f1", "name": "Work" } ], + "items": [ + { "type": 1, "name": "Example", "notes": "a note", + "login": { "uris": [ { "uri": "https://example.com" } ], + "username": "john", "password": "hunter2" } }, + { "type": 1, "name": "NoUriEntry", + "login": { "username": "jane", "password": "pw2" } }, + { "type": 2, "name": "Secure note", "notes": "not a login" }, + { "type": 1, "name": "Passwordless", + "login": { "username": "nobody", "password": "" } } + ] + } + """.trimIndent() + + @Test + fun `parses login items and skips the rest`() { + val result = BitwardenJsonParser.parse(export) + assertEquals(2, result.size, "only the two logins with a password") + + val example = result.first { it.username == "john" } + assertEquals("https://example.com", example.website) + assertEquals("hunter2", example.password) + assertEquals("a note", example.notes) + } + + @Test + fun `falls back to the item name when no URI is present`() { + val noUri = BitwardenJsonParser.parse(export).first { it.username == "jane" } + assertEquals("NoUriEntry", noUri.website) + assertEquals("pw2", noUri.password) + assertNull(noUri.notes) + } + + @Test + fun `an encrypted export yields nothing rather than ciphertext`() { + val encrypted = export.replace("\"encrypted\": false", "\"encrypted\": true") + assertTrue(BitwardenJsonParser.parse(encrypted).isEmpty()) + } + + @Test + fun `a password is read verbatim, keeping edge whitespace`() { + val json = + """ + { "encrypted": false, "items": [ + { "type": 1, "name": "Spaced", + "login": { "username": "u", "password": " pa ss " } } + ]} + """.trimIndent() + assertEquals(" pa ss ", BitwardenJsonParser.parse(json).single().password) + } + + @Test + fun `an all-whitespace password is preserved, not dropped`() { + val json = + """ + { "encrypted": false, "items": [ + { "type": 1, "name": "Spaces", + "login": { "username": "u", "password": " " } } + ]} + """.trimIndent() + assertEquals(" ", BitwardenJsonParser.parse(json).single().password) + } + + @Test + fun `sniffing recognises a Bitwarden export and rejects other text`() { + assertTrue(BitwardenJsonParser.looksLikeBitwarden(export)) + assertFalse(BitwardenJsonParser.looksLikeBitwarden("url,username,password\na,b,c")) + assertFalse(BitwardenJsonParser.looksLikeBitwarden("{\"not\": \"bitwarden\"}")) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/ImportFileReaderFormatTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/ImportFileReaderFormatTest.kt new file mode 100644 index 0000000000..f224fd1c4b --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/ImportFileReaderFormatTest.kt @@ -0,0 +1,144 @@ +package ai.rever.boss.services.importer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins the format routing in [ImportFileReader.parseContent]: the new Bitwarden + * JSON and KeePass XML paths are reached, and the pre-existing CSV path is not + * disturbed. + */ +class ImportFileReaderFormatTest { + @Test + fun `routes a Bitwarden JSON export to the Bitwarden parser`() { + val json = + """ + { "encrypted": false, "items": [ + { "type": 1, "name": "Example", + "login": { "uris": [ { "uri": "https://example.com" } ], + "username": "john", "password": "hunter2" } } + ]} + """.trimIndent() + val preview = ImportFileReader.parseContent("vault.json", json).getOrThrow() + assertEquals(1, preview.passwords.size) + assertEquals("john", preview.passwords.first().username) + } + + @Test + fun `routes a KeePass XML export to the KeePass parser`() { + val xml = + """ + + + + TitleExample + UserNamejohn + Passwordhunter2 + URLhttps://example.com + + + """.trimIndent() + val preview = ImportFileReader.parseContent("db.xml", xml).getOrThrow() + assertEquals(1, preview.passwords.size) + assertEquals("https://example.com", preview.passwords.first().website) + } + + @Test + fun `still routes a plain CSV to the CSV parser`() { + val csv = "url,username,password\nhttps://example.com,john,hunter2\n" + val preview = ImportFileReader.parseContent("passwords.csv", csv).getOrThrow() + assertEquals(1, preview.passwords.size) + assertEquals("hunter2", preview.passwords.first().password) + } + + @Test + fun `a CSV whose field contains the KeePass marker still parses as CSV`() { + val csv = "url,username,password,notes\nhttps://x.com,john,hunter2,\"see note\"\n" + val preview = ImportFileReader.parseContent("passwords.csv", csv).getOrThrow() + assertEquals(1, preview.passwords.size) + assertEquals("john", preview.passwords.first().username) + } + + @Test + fun `Bitwarden preview reports incomplete and native-app rows before import`() { + val json = """{"items":[ + {"type":1,"name":"example.test","login":{"username":"u","password":" keep "}}, + {"type":1,"name":"example.test","login":{"password":"p"}}, + {"type":1,"name":"example.test","login":{"username":"u","password":""}}, + {"type":1,"login":{"username":"u","password":"p","uris":[{"uri":"androidapp://com.example"}]}}, + {"type":1,"login":{"username":"u","password":"p","uris":[{"uri":"iosapp://com.example"}]}} + ]}""" + val preview = ImportFileReader.parseContent("vault.json", json).getOrThrow() + assertEquals(" keep ", preview.passwords.single().password) + assertEquals( + listOf( + SkipReason.MISSING_USERNAME, + SkipReason.MISSING_PASSWORD, + SkipReason.MISSING_URL, + SkipReason.MISSING_URL, + ), + preview.skipped.map { it.reason }, + ) + } + + @Test + fun `whitespace-only passwords are skipped to match the vault request contract`() { + val json = """{"items":[{"type":1,"name":"example.test", + "login":{"username":"u","password":" "}}]}""" + val preview = ImportFileReader.parseContent("vault.json", json).getOrThrow() + assertTrue(preview.passwords.isEmpty()) + assertEquals(SkipReason.MISSING_PASSWORD, preview.skipped.single().reason) + val csv = "url,username,password\nexample.test,u, \n" + val csvPreview = ImportFileReader.parseContent("vault.csv", csv).getOrThrow() + assertEquals(SkipReason.MISSING_PASSWORD, csvPreview.skipped.single().reason) + } + + @Test + fun `a web URI after an app association is retained`() { + val json = """{"items":[{"type":1,"login":{"username":"u","password":"p", + "uris":[{"uri":"androidapp://com.example"},{"uri":"https://example.test"}]}}]}""" + val preview = ImportFileReader.parseContent("vault.json", json).getOrThrow() + assertEquals("https://example.test", preview.passwords.single().website) + } + + @Test + fun `malformed JSON fields cannot escape as credential-bearing exceptions`() { + val json = """{"items":[{"type":1,"name":"example.test", + "login":{"username":"u","password":{"synthetic-secret":"value"}}}]}""" + val preview = ImportFileReader.parseContent("vault.json", json).getOrThrow() + assertTrue(preview.passwords.isEmpty()) + assertEquals(SkipReason.MISSING_PASSWORD, preview.skipped.single().reason) + val invalidHeader = """{"encrypted":{"synthetic-secret":"value"},"items":[]}""" + val failure = ImportFileReader.parseContent("vault.json", invalidHeader).exceptionOrNull() + assertTrue(failure is UnrecognisedImportFileException) + assertTrue( + failure.message + .orEmpty() + .contains("synthetic-secret") + .not(), + ) + } + + @Test + fun `KeePass preview explains incomplete credentials`() { + val xml = """ + Titleexample.test + Passwordp + Titleexample.test + UserNameu + """ + val preview = ImportFileReader.parseContent("vault.xml", xml).getOrThrow() + assertTrue(preview.passwords.isEmpty()) + assertEquals( + listOf(SkipReason.MISSING_USERNAME, SkipReason.MISSING_PASSWORD), + preview.skipped.map { it.reason }, + ) + } + + @Test + fun `an unrecognised file fails with a clear error`() { + val result = ImportFileReader.parseContent("mystery.txt", "just some prose, nothing structured") + assertTrue(result.isFailure) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/KeePassXmlParserTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/KeePassXmlParserTest.kt new file mode 100644 index 0000000000..b744aa3140 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/importer/KeePassXmlParserTest.kt @@ -0,0 +1,132 @@ +package ai.rever.boss.services.importer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pins [KeePassXmlParser] against a KeePass 2.x XML export, including the two + * format traps: an entry's `` must not be imported as extra credentials, + * and Recycle Bin entries must be skipped. Also checks XXE hardening. + */ +class KeePassXmlParserTest { + private val export = + """ + + + + True + UkVDWUNMRQ== + + + + Root + Um9vdA== + + TitleExample + UserNamejohn + Passwordhunter2 + URLhttps://example.com + Notesa note + + + Passwordoldpw + URLhttps://old.example.com + + + + + Subgroup + U3ViZ3JvdXA= + + TitleNested + Passwordnestedpw + + + + Recycle Bin + UkVDWUNMRQ== + + TitleDeleted + Passworddeletedpw + + + + + + """.trimIndent() + + @Test + fun `parses entries across nested groups`() { + val result = KeePassXmlParser.parse(export) + assertEquals(2, result.size, "Example and Nested only") + + val example = result.first { it.website == "https://example.com" } + assertEquals("john", example.username) + assertEquals("hunter2", example.password) + assertEquals("a note", example.notes) + + val nested = result.first { it.password == "nestedpw" } + assertEquals("Nested", nested.website, "URL absent, so the title is the website") + } + + @Test + fun `does not import an entry's history versions`() { + assertTrue(KeePassXmlParser.parse(export).none { it.password == "oldpw" }) + } + + @Test + fun `skips entries in the Recycle Bin`() { + assertTrue(KeePassXmlParser.parse(export).none { it.password == "deletedpw" }) + } + + @Test + fun `a disabled Recycle Bin still excludes its deleted entries`() { + val disabled = export.replace("True", "False") + assertEquals(2, KeePassXmlParser.parse(disabled).size) + assertTrue(KeePassXmlParser.parse(disabled).none { it.password == "deletedpw" }) + } + + @Test + fun `BOM is accepted and malformed XML never writes credential fragments to stderr`() { + assertEquals(2, KeePassXmlParser.parse("\uFEFF" + export).size) + val original = System.err + val captured = java.io.ByteArrayOutputStream() + try { + System.setErr(java.io.PrintStream(captured)) + assertTrue(KeePassXmlParser.parse("").isEmpty()) + assertEquals("", captured.toString()) + } finally { + System.setErr(original) + } + } + + @Test + fun `sniffing recognises a KeePass export and rejects other text`() { + assertTrue(KeePassXmlParser.looksLikeKeePass(export)) + assertTrue(KeePassXmlParser.looksLikeKeePass(""), "no xml declaration") + assertFalse(KeePassXmlParser.looksLikeKeePass("not keepass")) + } + + @Test + fun `a CSV whose data merely contains the marker is not misrouted to KeePass`() { + // The marker appears inside a field, not as the document root. + val csv = "url,username,password,notes\nhttps://x.com,u,p,\"\"\n" + assertFalse(KeePassXmlParser.looksLikeKeePass(csv), "root anchoring: a field value is not the root element") + } + + @Test + fun `a DOCTYPE with an external entity is refused, not expanded`() { + val xxe = + """ + + ]> + + Password&xxe; + + """.trimIndent() + // disallow-doctype-decl makes the parse fail; parse() returns empty rather than throwing. + assertTrue(KeePassXmlParser.parse(xxe).isEmpty()) + } +} From 65e83df0ceb93521b51c704abfed1b9f4cca1702 Mon Sep 17 00:00:00 2001 From: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:15:16 +0530 Subject: [PATCH 45/49] fix(filesystem): report failed file mutations (#680) * fix(filesystem): report failed file mutations * fix(filesystem): harden non-recursive deletion * test(filesystem): harden mutation regression coverage --------- Co-authored-by: ayush Co-authored-by: Shivang --- .../boss-service-filesystem/build.gradle.kts | 1 + .../filesystem/FileSystemServiceImpl.kt | 32 +- .../FileSystemServiceMutationTest.kt | 307 ++++++++++++++++++ 3 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceMutationTest.kt diff --git a/modules/boss-service-filesystem/build.gradle.kts b/modules/boss-service-filesystem/build.gradle.kts index 648d6885de..d7d33b0d29 100644 --- a/modules/boss-service-filesystem/build.gradle.kts +++ b/modules/boss-service-filesystem/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { runtimeOnly(libs.slf4j.simple) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.grpc.netty) } tasks.jar { diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt index 7dfb610480..1c0d172190 100644 --- a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt @@ -16,6 +16,7 @@ import java.io.File import java.io.IOException import java.nio.file.AccessDeniedException import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.DirectoryNotEmptyException import java.nio.file.FileAlreadyExistsException import java.nio.file.FileSystems import java.nio.file.Files @@ -160,16 +161,27 @@ class FileSystemServiceImpl : FileSystemServiceGrpcKt.FileSystemServiceCoroutine } } + // Directory creation and create I/O exception mapping retain their legacy behavior in this scoped fix. override suspend fun createFile(request: CreateFileRequest): Empty = withContext(Dispatchers.IO) { logger.info("createFile: path={}, isDirectory={}", request.path, request.isDirectory) validatePath(request.path) val file = File(request.path) if (request.createParents) file.parentFile?.mkdirs() - if (request.isDirectory) file.mkdirs() else file.createNewFile() + if (request.isDirectory) { + file.mkdirs() + } else if (!file.createNewFile()) { + throw status(Status.ALREADY_EXISTS, "File already exists: ${request.path}", null) + } Empty.getDefaultInstance() } + /** + * Nonrecursive deletion preserves missing-target success for RPC compatibility. NIO removes the + * directory entry without following links and supplies typed failures that survive gRPC as statuses. + * Recursive deletion retains its legacy unchecked behavior; changing that contract is separate work. + * Only AccessDeniedException maps to PERMISSION_DENIED; other provider I/O errors remain INTERNAL. + */ override suspend fun deleteFile(request: DeleteFileRequest): Empty = withContext(Dispatchers.IO) { logger.info("deleteFile: path={}, recursive={}", request.path, request.recursive) @@ -177,6 +189,24 @@ class FileSystemServiceImpl : FileSystemServiceGrpcKt.FileSystemServiceCoroutine val file = File(request.path) if (request.recursive && file.isDirectory) { file.deleteRecursively() + } else if (!request.recursive) { + try { + Files.deleteIfExists(file.toPath()) + } catch (e: DirectoryNotEmptyException) { + throw status( + Status.FAILED_PRECONDITION, + "Cannot delete non-empty directory without recursive=true: ${request.path}", + e, + ) + } catch (e: AccessDeniedException) { + throw status(Status.PERMISSION_DENIED, "Access denied: ${request.path}", e) + } catch (e: IOException) { + throw status( + Status.INTERNAL, + "Failed to delete path: ${request.path}: ${e.message ?: e::class.java.simpleName}", + e, + ) + } } else { file.delete() } diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceMutationTest.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceMutationTest.kt new file mode 100644 index 0000000000..2b4185b953 --- /dev/null +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceMutationTest.kt @@ -0,0 +1,307 @@ +package ai.rever.boss.service.filesystem + +import ai.rever.boss.ipc.proto.services.CreateFileRequest +import ai.rever.boss.ipc.proto.services.DeleteFileRequest +import ai.rever.boss.ipc.proto.services.FileSystemServiceGrpcKt +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.Status +import io.grpc.StatusException +import io.grpc.netty.NettyServerBuilder +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import java.io.File +import java.io.IOException +import java.net.InetSocketAddress +import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS +import java.nio.file.attribute.PosixFilePermission +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FileSystemServiceMutationTest { + private val service = FileSystemServiceImpl() + private val testDirectory = + File.createTempFile("filesystem-service-mutation-", "").let { file -> + file.delete() + file.mkdirs() + file + } + + @AfterTest + fun cleanUp() { + testDirectory.deleteRecursively() + } + + @Test + fun `create regular file succeeds`() { + val file = testDirectory.resolve("new-file.txt") + + createFile(file) + + assertTrue(file.isFile) + } + + @Test + fun `create existing regular file is refused`() { + val file = testDirectory.resolve("existing-file.txt").apply { createNewFile() } + + val error = assertFailsWith { createFile(file) } + + assertEquals(Status.Code.ALREADY_EXISTS, error.status.code) + assertEquals("File already exists: ${file.absolutePath}", error.status.description) + assertTrue(file.isFile) + } + + @Test + fun `create file at directory path is refused`() { + val directory = testDirectory.resolve("occupied-by-directory").apply { mkdir() } + + val error = assertFailsWith { createFile(directory) } + + assertEquals(Status.Code.ALREADY_EXISTS, error.status.code) + assertEquals("File already exists: ${directory.absolutePath}", error.status.description) + assertTrue(directory.isDirectory) + } + + @Test + fun `gRPC client receives non-recursive delete refusal status and description`() { + val directory = testDirectory.resolve("non-empty-over-grpc").apply { mkdir() } + directory.resolve("child.txt").createNewFile() + + withGrpcService { stub -> + val error = + assertFailsWith { + runBlocking { + stub.deleteFile( + DeleteFileRequest + .newBuilder() + .setPath(directory.absolutePath) + .setRecursive(false) + .build(), + ) + } + } + + assertEquals(Status.Code.FAILED_PRECONDITION, error.status.code) + assertEquals( + "Cannot delete non-empty directory without recursive=true: ${directory.absolutePath}", + error.status.description, + ) + assertTrue(directory.isDirectory) + assertTrue(directory.resolve("child.txt").isFile) + } + } + + @Test + fun `create with a missing parent preserves the I O exception`() { + val file = testDirectory.resolve("missing-parent/file.txt") + + assertFailsWith { createFile(file) } + + assertFalse(file.exists()) + } + + @Test + fun `create with a regular-file parent preserves the I O exception`() { + val parent = testDirectory.resolve("regular-file-parent").apply { createNewFile() } + val file = parent.resolve("child.txt") + + assertFailsWith { createFile(file) } + + assertFalse(file.exists()) + } + + @Test + fun `delete regular file succeeds`() { + val file = testDirectory.resolve("delete-me.txt").apply { createNewFile() } + + deleteFile(file, recursive = false) + + assertFalse(file.exists()) + } + + @Test + fun `delete empty directory succeeds`() { + val directory = testDirectory.resolve("empty-directory").apply { mkdir() } + + deleteFile(directory, recursive = false) + + assertFalse(directory.exists()) + } + + @Test + fun `delete non-empty directory without recursion is refused`() { + val directory = testDirectory.resolve("non-empty-directory").apply { mkdir() } + val child = directory.resolve("child.txt").apply { createNewFile() } + + val error = assertFailsWith { deleteFile(directory, recursive = false) } + + assertEquals(Status.Code.FAILED_PRECONDITION, error.status.code) + assertEquals( + "Cannot delete non-empty directory without recursive=true: ${directory.absolutePath}", + error.status.description, + ) + assertTrue(directory.isDirectory) + assertTrue(child.isFile) + } + + @Test + fun `non-recursive delete reports permission denial for a protected dangling symlink`() { + val parent = testDirectory.resolve("protected-parent").apply { mkdir() } + val link = parent.resolve("dangling-link") + val permissions = createProtectedDanglingSymlink(parent, link) + + try { + // Establish the environment before invoking the service; never turn a failed assertion into a skip. + assumeTrue("The parent remains writable despite the permission fixture", !Files.isWritable(parent.toPath())) + val error = assertFailsWith { deleteFile(link, recursive = false) } + + assertEquals(Status.Code.PERMISSION_DENIED, error.status.code) + assertEquals("Access denied: ${link.absolutePath}", error.status.description) + assertTrue(Files.exists(link.toPath(), NOFOLLOW_LINKS)) + } finally { + Files.setPosixFilePermissions(parent.toPath(), permissions) + } + } + + @Test + fun `non-recursive delete removes a symlink without deleting its target`() { + val target = testDirectory.resolve("retained-target.txt").apply { writeText("keep me") } + val link = testDirectory.resolve("removable-link") + try { + Files.createSymbolicLink(link.toPath(), target.toPath()) + } catch (_: UnsupportedOperationException) { + assumeTrue("Symbolic links are unavailable on this platform", false) + } catch (_: IOException) { + assumeTrue("The environment cannot create the symbolic-link fixture", false) + } + + deleteFile(link, recursive = false) + + assertFalse(Files.exists(link.toPath(), NOFOLLOW_LINKS)) + assertEquals("keep me", target.readText()) + } + + @Test + fun `gRPC create with a missing parent retains the deferred UNKNOWN status`() { + val file = testDirectory.resolve("missing-grpc-parent/file.txt") + withGrpcService { stub -> + // Create IOException mapping is deferred; this pins the actual wire behavior, not the direct exception. + val error = + assertFailsWith { + runBlocking { + stub.createFile(CreateFileRequest.newBuilder().setPath(file.absolutePath).build()) + } + } + assertEquals(Status.Code.UNKNOWN, error.status.code) + assertEquals(null, error.status.description) + assertFalse(file.exists()) + } + } + + @Test + fun `delete missing target remains successful`() { + val missingFile = testDirectory.resolve("missing.txt") + + deleteFile(missingFile, recursive = false) + + assertFalse(missingFile.exists()) + } + + @Test + fun `recursive delete of a missing target remains successful`() { + val missingFile = testDirectory.resolve("missing-recursively.txt") + + deleteFile(missingFile, recursive = true) + + assertFalse(missingFile.exists()) + } + + @Test + fun `recursive delete succeeds`() { + val directory = testDirectory.resolve("recursive-directory").apply { mkdir() } + val nestedDirectory = directory.resolve("nested").apply { mkdir() } + nestedDirectory.resolve("child.txt").createNewFile() + + deleteFile(directory, recursive = true) + + assertFalse(directory.exists()) + } + + private fun createFile(file: File) { + runBlocking { + service.createFile( + CreateFileRequest + .newBuilder() + .setPath(file.absolutePath) + .setIsDirectory(false) + .build(), + ) + } + } + + private fun deleteFile( + file: File, + recursive: Boolean, + ) { + runBlocking { + service.deleteFile( + DeleteFileRequest + .newBuilder() + .setPath(file.absolutePath) + .setRecursive(recursive) + .build(), + ) + } + } + + private fun createProtectedDanglingSymlink( + parent: File, + link: File, + ): Set { + val permissions = + try { + Files.getPosixFilePermissions(parent.toPath()) + } catch (_: UnsupportedOperationException) { + assumeTrue("POSIX permissions are unavailable on this platform", false) + return emptySet() + } + + try { + Files.createSymbolicLink(link.toPath(), parent.resolve("missing-target").toPath()) + Files.setPosixFilePermissions(parent.toPath(), permissions - PosixFilePermission.OWNER_WRITE) + } catch (_: UnsupportedOperationException) { + assumeTrue("Symbolic links are unavailable on this platform", false) + } catch (_: IOException) { + assumeTrue("The environment cannot create the dangling-symlink fixture", false) + } + return permissions + } + + private fun withGrpcService(block: (FileSystemServiceGrpcKt.FileSystemServiceCoroutineStub) -> Unit) { + val server: Server = + NettyServerBuilder + .forAddress(InetSocketAddress("127.0.0.1", 0)) + .addService(service) + .build() + .start() + val channel: ManagedChannel = + ManagedChannelBuilder + .forAddress("127.0.0.1", server.port) + .usePlaintext() + .build() + try { + block(FileSystemServiceGrpcKt.FileSystemServiceCoroutineStub(channel)) + } finally { + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS) + server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS) + } + } +} From 35e80f9a42bdf6a078f49d626f63dad300a48e43 Mon Sep 17 00:00:00 2001 From: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:16:45 +0530 Subject: [PATCH 46/49] fix(updater): stop the update prompt stacking over another modal (#696) (#697) * fix(updater): stop the update prompt stacking over another modal (#696) On the heavyweight-overlay path (Windows) every modal is a separate always-on-top window. The app-update prompt is the one dialog that appears on its own timer rather than in response to the user, so it could pop up over a dialog the user was filling in, or another dialog could open over it. Two always-on-top heavyweight modal windows stacked is the state #696 reports as leaving BOSS unusable. HeavyweightModalRegistry counts the open heavyweight modals (UI-thread DisposableEffect in HeavyweightModal, mirroring the existing popup counter). The update prompt now yields to any other heavyweight modal in either direction - it does not open over one, and it steps aside when one opens over it - reappearing once that modal closes. The banner stays up meanwhile. The count is always 0 on the lightweight path, where Compose stacks dialogs correctly on its own, so that path is unchanged. shouldShowUpdateDialog is a pure gate (subtracting the prompt's own window out of the count so it does not hide itself), pinned by UpdateDialogGateTest across the open / stay / step-aside / return sequence. * fix(updater): pin modal registration ordering and test lifecycle --------- Co-authored-by: Shivang --- .../ai/rever/boss/app/BossAppScaffold.kt | 9 +- .../overlays/HeavyweightModalRegistry.kt | 39 +++++ .../ai/rever/boss/updater/UpdateDialogGate.kt | 58 +++++++ .../components/overlays/HeavyweightModal.kt | 2 + .../boss/updater/UpdateDialogGateTest.kt | 126 ++++++++++++++ .../boss/updater/UpdateDialogLifecycleTest.kt | 154 ++++++++++++++++++ 6 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModalRegistry.kt create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/updater/UpdateDialogGate.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogGateTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogLifecycleTest.kt diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt index 838431bf81..e1dda281e7 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt @@ -60,6 +60,7 @@ import ai.rever.boss.plugin.ui.BossTheme import ai.rever.boss.services.bookmarks.BookmarkAPIAccess import ai.rever.boss.updater.UpdateAvailableDialog import ai.rever.boss.updater.UpdateBanner +import ai.rever.boss.updater.UpdateDialogGate import ai.rever.boss.updater.UpdateState import ai.rever.boss.updater.drawsBanner import ai.rever.boss.updater.rememberUpdateDialogOwnership @@ -571,7 +572,13 @@ internal fun BossAppScaffold( val showUpdateDialog by updateHandle.showUpdateDialog.collectAsState() val isUpdateDialogOwner = rememberUpdateDialogOwnership(state.windowId) val updateStateForDialog = updateState - if (showUpdateDialog && isUpdateDialogOwner && updateStateForDialog is UpdateState.UpdateAvailable) { + UpdateDialogGate( + wantDialog = showUpdateDialog, + isOwner = isUpdateDialogOwner, + updateAvailable = updateStateForDialog is UpdateState.UpdateAvailable, + ) { + // Re-test for the smart cast inside the gated content. + if (updateStateForDialog !is UpdateState.UpdateAvailable) return@UpdateDialogGate UpdateAvailableDialog( updateInfo = updateStateForDialog.updateInfo, onUpdateNow = { diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModalRegistry.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModalRegistry.kt new file mode 100644 index 0000000000..b3ae701d38 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModalRegistry.kt @@ -0,0 +1,39 @@ +package ai.rever.boss.components.overlays + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** + * Process-wide count of composed heavyweight modals, including plugin dialogs and the update prompt. + * UI-thread only: [TrackHeavyweightModal] pairs each acquisition with composition disposal. + * + * Only the update prompt yields to this count. Other autonomous prompts (including timed MCP + * approvals) remain ungated. Native and Settings lightweight dialogs are not represented here. + * The process-wide OFF_SCREEN path never acquires a registration. + */ +object HeavyweightModalRegistry { + var openCount: Int by mutableStateOf(0) + private set + + /** Called by [HeavyweightModal] when it enters composition. UI thread only. */ + fun acquire() { + openCount += 1 + } + + /** Called by [HeavyweightModal] when it leaves composition. UI thread only. */ + fun release() { + openCount = (openCount - 1).coerceAtLeast(0) + } +} + +/** Register in the caller's composition, before entering Window's separate content composition. */ +@Composable +internal fun TrackHeavyweightModal() { + DisposableEffect(Unit) { + HeavyweightModalRegistry.acquire() + onDispose { HeavyweightModalRegistry.release() } + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/updater/UpdateDialogGate.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/updater/UpdateDialogGate.kt new file mode 100644 index 0000000000..b5116467ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/updater/UpdateDialogGate.kt @@ -0,0 +1,58 @@ +package ai.rever.boss.updater + +import ai.rever.boss.components.overlays.HeavyweightModalRegistry +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * Whether the app-update prompt should be on screen right now. + * + * The update prompt can appear on its own timer (a background update check + * finishing) rather than in response to the user. On the heavyweight-overlay path (Windows), every + * modal is a separate always-on-top window, and two of them stacked is the state BossConsole#696 + * reports as leaving BOSS unusable. So the prompt yields to any OTHER heavyweight modal: it does not + * open over one, and it steps aside when one opens over it. The update banner still shows meanwhile, + * and the prompt reappears once the other modal closes. + * + * [openHeavyweightModals] is the total from [ai.rever.boss.components.overlays.HeavyweightModalRegistry], + * which counts the update prompt itself while it is up - so its own window is subtracted out via + * [updateDialogShowing], leaving the count of modals that are NOT this prompt. On the lightweight + * process-wide OFF_SCREEN path the count is always 0. The count spans all BOSS windows, so another + * window's heavyweight modal also defers this prompt. Native and Settings lightweight dialogs are + * not counted; this gate does not serialize other autonomous prompts or prove #696 fully resolved. + */ +internal fun shouldShowUpdateDialog( + wantDialog: Boolean, + isOwner: Boolean, + updateAvailable: Boolean, + openHeavyweightModals: Int, + updateDialogShowing: Boolean, +): Boolean { + if (!wantDialog || !isOwner || !updateAvailable) return false + val otherModals = openHeavyweightModals - if (updateDialogShowing) 1 else 0 + return otherModals <= 0 +} + +/** Keeps count invalidations local and commits self-accounting before the modal's registration. */ +@Composable +internal fun UpdateDialogGate( + wantDialog: Boolean, + isOwner: Boolean, + updateAvailable: Boolean, + content: @Composable () -> Unit, +) { + var showing by remember { mutableStateOf(false) } + if (shouldShowUpdateDialog(wantDialog, isOwner, updateAvailable, HeavyweightModalRegistry.openCount, showing)) { + // TrackHeavyweightModal commits in this same parent composition, after this effect. + // Keeping its registration outside Window's separate content composition pins that order. + DisposableEffect(Unit) { + showing = true + onDispose { showing = false } + } + content() + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModal.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModal.kt index 75d2eb4642..2288f87671 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModal.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightModal.kt @@ -56,6 +56,8 @@ fun HeavyweightModal( // dismisses on Escape and on its own scrim - only the focus-loss path is suppressed. val dismissOnFocusLoss = LocalDismissModalOnFocusLoss.current + TrackHeavyweightModal() + Window( onCloseRequest = onDismissRequest, state = state, diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogGateTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogGateTest.kt new file mode 100644 index 0000000000..301d45fc10 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogGateTest.kt @@ -0,0 +1,126 @@ +package ai.rever.boss.updater + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pins [shouldShowUpdateDialog], the gate that keeps the autonomous app-update prompt from stacking + * a second always-on-top window over another heavyweight modal (BossConsole#696). + * + * The convergence argument the composable relies on is checked here as a sequence: the prompt opens + * only when nothing else is up, keeps itself up once it is the only modal, steps aside when another + * modal opens over it, and comes back when that modal closes - with no oscillation, because it + * subtracts its own window out of the count. + */ +class UpdateDialogGateTest { + @Test + fun `the base conditions still gate it`() { + assertFalse( + shouldShowUpdateDialog( + wantDialog = false, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 0, + updateDialogShowing = false, + ), + "no request means no dialog", + ) + assertFalse( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = false, + updateAvailable = true, + openHeavyweightModals = 0, + updateDialogShowing = false, + ), + "a non-owning window never hosts it", + ) + assertFalse( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = false, + openHeavyweightModals = 0, + updateDialogShowing = false, + ), + "no available update means no dialog", + ) + } + + @Test + fun `it opens when nothing else is on screen`() { + assertTrue( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 0, + updateDialogShowing = false, + ), + ) + } + + @Test + fun `it does not open over a modal already up`() { + assertFalse( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 1, + updateDialogShowing = false, + ), + ) + } + + @Test + fun `once it is the only modal it stays - its own window is not counted against it`() { + assertTrue( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 1, + updateDialogShowing = true, + ), + ) + } + + @Test + fun `it steps aside when a second modal opens over it`() { + assertFalse( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 2, + updateDialogShowing = true, + ), + ) + } + + @Test + fun `it stays hidden while the other modal is up, then returns when it closes`() { + // The other modal is still up and the prompt has already stepped aside (showing = false). + assertFalse( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 1, + updateDialogShowing = false, + ), + ) + // The other modal closes. + assertTrue( + shouldShowUpdateDialog( + wantDialog = true, + isOwner = true, + updateAvailable = true, + openHeavyweightModals = 0, + updateDialogShowing = false, + ), + ) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogLifecycleTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogLifecycleTest.kt new file mode 100644 index 0000000000..bc3077d97b --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/updater/UpdateDialogLifecycleTest.kt @@ -0,0 +1,154 @@ +package ai.rever.boss.updater + +import ai.rever.boss.components.overlays.HeavyweightModalRegistry +import ai.rever.boss.components.overlays.TrackHeavyweightModal +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composition +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals + +class UpdateDialogLifecycleTest { + @Test + fun `real effects yield return and dispose without self oscillation or leaking registrations`() = + runBlocking { + assertEquals(0, HeavyweightModalRegistry.openCount) + var promptMounts = 0 + var promptDisposals = 0 + withCompositions { owner, otherWindow, settle -> + val otherOpen = mutableStateOf(false) + val ownsPrompt = mutableStateOf(true) + val requested = mutableStateOf(true) + + owner.setContent { + UpdateDialogGate(requested.value, ownsPrompt.value, updateAvailable = true) { + TrackHeavyweightModal() + DisposableEffect(Unit) { + promptMounts++ + onDispose { promptDisposals++ } + } + } + } + otherWindow.setContent { + if (otherOpen.value) TrackHeavyweightModal() + } + settle() + assertEquals(1, HeavyweightModalRegistry.openCount) + assertEquals(1, promptMounts) + assertEquals(0, promptDisposals) + + otherOpen.value = true + settle() + assertEquals(1, HeavyweightModalRegistry.openCount) + assertEquals(1, promptMounts) + assertEquals(1, promptDisposals) + assertEquals(true, requested.value, "yielding must not dismiss the requested update") + + otherOpen.value = false + settle() + assertEquals(1, HeavyweightModalRegistry.openCount) + assertEquals(2, promptMounts) + assertEquals(1, promptDisposals) + + ownsPrompt.value = false + settle() + assertEquals(0, HeavyweightModalRegistry.openCount) + assertEquals(2, promptDisposals) + + // Other-modal-first: ownership returns while another window still has a modal. + otherOpen.value = true + settle() + ownsPrompt.value = true + settle() + assertEquals(2, promptMounts) + assertEquals(1, HeavyweightModalRegistry.openCount) + otherOpen.value = false + settle() + assertEquals(3, promptMounts) + + requested.value = false + settle() + assertEquals(0, HeavyweightModalRegistry.openCount) + assertEquals(3, promptDisposals) + requested.value = true + settle() + assertEquals(4, promptMounts) + } + assertEquals(0, HeavyweightModalRegistry.openCount) + assertEquals(promptMounts, promptDisposals) + } + + private suspend fun withCompositions(block: suspend (Composition, Composition, suspend () -> Unit) -> Unit) { + val clock = BroadcastFrameClock() + withContext(clock) { + val recomposer = Recomposer(coroutineContext) + val runner = launch { recomposer.runRecomposeAndApplyChanges() } + val frames = + launch { + while (isActive) { + clock.sendFrame(System.nanoTime()) + delay(1) + } + } + val owner = Composition(NoNodes(), recomposer) + val otherWindow = Composition(NoNodes(), recomposer) + + suspend fun settle() { + withTimeout(5_000) { + // Several frames also catch a feedback loop that merely passed through + // the expected state before opening and closing again. + repeat(5) { + Snapshot.sendApplyNotifications() + delay(10) + recomposer.awaitIdle() + } + } + } + try { + block(owner, otherWindow, ::settle) + } finally { + owner.dispose() + otherWindow.dispose() + recomposer.cancel() + runner.cancelAndJoin() + frames.cancelAndJoin() + } + } + } + + private class NoNodes : AbstractApplier(Unit) { + override fun insertTopDown( + index: Int, + instance: Unit, + ) = Unit + + override fun insertBottomUp( + index: Int, + instance: Unit, + ) = Unit + + override fun remove( + index: Int, + count: Int, + ) = Unit + + override fun move( + from: Int, + to: Int, + count: Int, + ) = Unit + + override fun onClear() = Unit + } +} From 77c4a407147ec2ed5beb64389cd052c8c7df503d Mon Sep 17 00:00:00 2001 From: Nikhil jangir Date: Tue, 15 Sep 2026 11:24:23 +0530 Subject: [PATCH 47/49] fix(plugins): keep a plugin's app-wide registrations when another window closes (#694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Respect horizontal scrollers and commit trackpad navigation on release * Fix swipe cancellation, observer lifecycle and terminal delivery * Publish prior contact termination for home swipe attribution * fix(kernel): require verified caller identity for the run-configuration bridge (#634) * fix(overlays): honor RTL and fractional placement (#477) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot sw… * fix(secrets): make the secret listing RPCs fail soft on one bad row (#668) * fix(secrets): make the secret listing RPCs fail soft on one bad row BossConsole#620: get_user_secrets, search_user_secrets and get_user_secrets_with_shared each decrypt password_encrypted and recovery_codes_encrypted with a bare call to decrypt_text inside a set-returning query. decrypt_text raises on an undecryptable value - a wrong key after a partial key rotation, storage corruption, or an HMAC/padding mismatch - and one raised row aborts the whole query, so the caller sees every one of their secrets fail to load rather than just the bad one. twofa_secret already reads through safe_decrypt_twofa_secret (NULL on failure). recovery_codes_encrypted already had a safe wrapper too - safe_decrypt_recovery_codes, whose own doc comment says "used internally by get_user_secrets functions" - but whichever later migration touched these RPCs stopped calling it and fell back to raw decrypt_text, so it sat unused. password_encrypted never had a safe wrapper at all. Adds try_decrypt_text(text), mirroring safe_decrypt_recovery_codes's BEGIN/EXCEPTION WHEN OTHERS THEN RETURN NULL shape for a scalar result, locked down the same way safe_decrypt_twofa_secret is (no client role gets EXECUTE). Routes the password read in all three RPCs through it and the recovery-codes read back through the existing safe wrapper. One corrupt row now surfaces as a blanked field - password NULL, recovery_codes [] - while every other secret in the page still loads. Deliberately not named safe_decrypt_*: ops/rotate_master_encryption_key.sql's re-encrypt loop treats every function matching that name pattern as a mapped bespoke-format adapter and refuses to run if one is unmapped. try_decrypt_text wraps the same plain envelope that script already re-encrypts directly, so it must stay outside that sweep. Scoped to the three user-facing listing RPCs only; the operator-run backfill/rotation paths keep using raw decrypt_text and should still fail closed. * Fix installed-client compatibility and preserve systemic decryption failures Includes desktop regression coverage from #621 by Rushikeshiitb and review repairs by kshivang; related independent #652 work by akashjadon04 informed the review. --------- Co-authored-by: Shivang * fix(kernel): require verified caller identity for the project-data bridge (#666) * fix(kernel): require verified caller identity for the project-data bridge BossConsole#53: ProjectDataServiceBridge 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 watch every recently opened project's path (which routinely contains a username) with no credential, and could force an arbitrary window to switch its open project via selectProject. That is not only a UI surprise: a project switch publishes a ProjectChangeEvent on the application-wide event bus every installed plugin can subscribe to, so an unauthenticated caller could trigger that broadcast on demand. Applies the same fail-closed pattern already used for ActiveTabs, Download, Git, Log, PluginUI, RoleManagement, RunConfig and Supabase: every unary RPC requires AUTHENTICATED_PROCESS_ID, the watch stream captures CURRENT_IDENTITY before collecting and re-checks it on every emission so a revoked token stops a live stream, and every mutation gets an attributed audit log line. SplitView remains the one unguarded service on this server. Rewrote ProjectDataServiceBridgeTest against a real gRPC server (the existing tests called the bridge directly, which the new gating would have refused outright) - refusal-with-no-credential, authenticated mutation, revoked unary and revoked stream cases, plus the two existing routing guarantees now exercised over an authenticated channel: selectProject's UI-dispatcher confinement and watchRecentProjects reading the process-wide ProjectState singleton rather than a per-window mirror. Also folded the stale, repeatedly-drifting enumeration of which bridges fail closed out of BossIpcServer's own KDoc - it named only two of what are now nine - in favor of pointing at each bridge's own KDoc instead. * fix(review): clarify project identity guarantees and harden test lifecycle * style(test): keep filtered flow chain continuation together --------- Co-authored-by: Shivang * fix(git): make clone timeout cancellable (#660) Fixes #641. Consolidates the independently implemented clone-timeout work from #659 and #660. Abhiram Nair authored the original onExit lifecycle, deterministic process tests, real process-tree coverage and public clone cleanup/retry repairs. Antriksh independently authored the interruptible-wait alternative and real-Git acceptance tests; target preservation, ordinary failure and final-completion coverage are incorporated with explicit credit. Maintainer repairs serialize interruptible progress delivery, make callback failures advisory and preserve successful exit during delayed notification. Co-authored-by: passionate-coder26 Co-authored-by: Antriksh * fix(kernel): require verified caller identity for the split-view bridge (#672) * 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. * test(kernel): verify split-view identity coverage and clarify scope --------- Co-authored-by: Shivang * feat(mcp): let operators set tool policy proactively (#636) * feat(search): index open tabs by their URL and file path (#606) (#648) Global Search matched an open tab only against its title, and TabResult hardcoded url = null / filePath = null on every hit. A browser tab whose title never mentions its domain, or an editor tab someone wants to find by path rather than filename, could not be found at all. searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and an editor tab's EditorTabInfo.filePath, through the same substring- containment gate (proseScore) searchRecentPages already uses for a page's URL, since a fuzzy subsequence match over a whole URL or path is close to meaningless. TabResult carries the real url/filePath instead of null, and the tab row's subtitle now leads with the workspace name (disambiguating identical URLs open in different workspaces, or across windows) followed by the url/filePath/tabType, in place of the old "tabType • workspaceName" line. 11 tests cover: URL-only and file-path-only matches, both fields populated for the right tab type and left null for the other/a blank path, a short query not matching a URL by fuzzy accident, title+URL both matching taking the higher (title) score with title highlight ranges preserved, live navigation changing which URL matches (and the old URL no longer matching), directory-path matching without a subsequence false-positive, and no-open-tabs contributing nothing rather than failing. * fix(browser): replace superseded capture requests (#509) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate d… * feat(mcp): add a clickable activity log for recent tool calls (#662) * feat(search): index open tabs by their URL and file path (#606) (#648) Global Search matched an open tab only against its title, and TabResult hardcoded url = null / filePath = null on every hit. A browser tab whose title never mentions its domain, or an editor tab someone wants to find by path rather than filename, could not be found at all. searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and an editor tab's EditorTabInfo.filePath, through the same substring- containment gate (proseScore) searchRecentPages already uses for a page's URL, since a fuzzy subsequence match over a whole URL or path is close to meaningless. TabResult carries the real url/filePath instead of null, and the tab row's subtitle now leads with the workspace name (disambiguating identical URLs open in different workspaces, or across windows) followed by the url/filePath/tabType, in place of the old "tabType • workspaceName" line. 11 tests cover: URL-only and file-path-only matches, both fields populated for the right tab type and left null for the other/a blank path, a short query not matching a URL by fuzzy accident, title+URL both matching taking the higher (title) score with title highlight ranges preserved, live navigation changing which URL matches (and the old URL no longer matching), directory-path matching without a subsequence false-positive, and no-open-tabs contributing nothing rather than failing. * fix(browser): replace superseded capture requests (#509) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * V… * Fix/spotlight project session isolation (#593) * fix(overlays): honor RTL and fractional placement (#477) * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * … * fix(plugins): keep a plugin's app-wide registrations when another window closes Every window owns a DefaultPlugin and a DynamicPluginManager and loads its own copy of every plugin, but seven kinds of registration go to process-wide registries keyed only by id: MCP tool providers, search providers, panel menus, settings pages, deep-link actions, shortcut providers and status-bar items. A second window's copy replaced the first window's entry, and closing either window (disposeWindow force-uninstalls every plugin, and each teardown unregisters by id) removed the id for the whole app while the other window still had the plugin loaded. In the released app an attached agent's BOSS MCP tools went from 29 to 16 when a second window was closed: every plugin tool, until restart. WindowRegistrations keeps, per registry and id, the windows that registered it, newest last. Registering serves the newest, as before. Unregistering removes only that window's entry: if it was served and another window still holds one, that one is served again; if none is left, the id is withdrawn; a window with no entry changes nothing. DefaultPlugin routes all seven kinds through it and releases what a closed window still holds. Locking is per registry and id, so plugin code run while publishing never holds a lock another plugin waits on. The registries, McpToolRegistryImpl and the plugin context wrappers are unchanged. * fix(plugins): avoid unrelated registration locks during window release * fix(plugins): fence registrations after window release --------- Signed-off-by: dependabot[bot] Co-authored-by: Shivanshu Co-authored-by: Antriksh Co-authored-by: Shikhar Goel Co-authored-by: Nilesh Vasita Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Shivang Co-authored-by: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Co-authored-by: Patel Karma Rohit Co-authored-by: Aditya8369 Co-authored-by: passionate-coder26 Co-authored-by: rehannayeem0786 Co-authored-by: rehannayeem0786 <128595058+rehannayeem0786@users.noreply.github.com> Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shwetas Dhake Co-authored-by: AdityaK-iiita Co-authored-by: Oleksii (Lexi) Kyrylchuk Co-authored-by: Sahvendra Co-authored-by: Aishwary Anand <201525941+Aishwary11-11@users.noreply.github.com> Co-authored-by: Aishwary Anand Co-authored-by: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Co-authored-by: ayush Co-authored-by: Anshul Pundhir <129892939+pundhiranshul@users.noreply.github.com> Co-authored-by: Sanjay Co-authored-by: arjun28115 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Achintya Chaurasia Co-authored-by: surfthewave-commits Co-authored-by: Ayushman <139611211+InfoSage05@users.noreply.github.com> Co-authored-by: Kanak Khandelwal <152528927+Kanakk23@users.noreply.github.com> Co-authored-by: Ishaan Chowdhury Co-authored-by: Karan Raj Co-authored-by: john_cybersage Co-authored-by: aryanbajaj868 <117334799+aryanbajaj868@users.noreply.github.com> Co-authored-by: Risa Labs Co-authored-by: john k Co-authored-by: kshivang Co-authored-by: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Co-authored-by: pundhiranshul Co-authored-by: Jaspreet Singh --- AGENTS.md | 15 + .../boss/components/plugin/DefaultPlugin.kt | 97 +++-- .../components/plugin/WindowRegistrations.kt | 165 ++++++++ .../PluginRegistrationsAcrossWindowsTest.kt | 380 ++++++++++++++++++ .../plugin/WindowRegistrationsTest.kt | 261 ++++++++++++ 5 files changed, 892 insertions(+), 26 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/WindowRegistrations.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/PluginRegistrationsAcrossWindowsTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/WindowRegistrationsTest.kt diff --git a/AGENTS.md b/AGENTS.md index 21fa8049c5..fd04395b67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2158,6 +2158,21 @@ rows, and the summary distinguishes rules being replaced from new denials. Global None is a distinct deny-all preset, not Custom; Edit is the label at both levels. Search by plugin display name also matches its saved tool rules. +## Process-wide plugin registrations belong to window lifetimes + +`DefaultPlugin` routes MCP/search providers, panel menus, settings pages, deep-link actions, +shortcut providers and status-bar items through `WindowRegistrations`. The newest window's +registration serves every window; unregistering it restores the newest surviving owner. +Disabling or dynamically unregistering in one window therefore leaves another window's +registration available. Per-window action dispatch is not implemented by this arbitration. + +Each window has a lifetime token. Release fences later registrations and snapshots admitted +slots under a short owner lock; publication checks the fence under its slot lock. Plugin +callbacks never run under the owner lock, and disposal does not wait on unowned slots. +Restoring a shared id re-queries tools()/shortcuts() on the closing thread, so a slow surviving +provider can delay that close. Replacement warnings and snapshot-at-registration semantics +are intentional. Global access filters still apply independently of registration ownership. + ### Plugin Dev Staging & Launchpad Invariants - **Protected Plugins Overrule Dev JARs Unconditionally**: diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt index 8365e23a36..33b8b38210 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/DefaultPlugin.kt @@ -161,7 +161,59 @@ class DefaultPlugin( private val workspaceManager: ai.rever.boss.components.workspaces.WorkspaceManager? = null, private val splitViewState: ai.rever.boss.components.window_panel.SplitViewState? = null, ) : PluginContext { + private val registrationOwner = WindowRegistrations.Owner() + companion object { + /** + * Which window's plugin copy each process-wide registration belongs to. Shared by every + * window's DefaultPlugin because the registries it arbitrates are shared; see + * [WindowRegistrations] for why closing one window must not remove another's. + */ + private val registrations = WindowRegistrations() + + private val searchProviders = + WindowRegistrations.Target( + "search", + SearchRegistryImpl::registerProvider, + SearchRegistryImpl::unregisterProvider, + ) + private val mcpToolProviders = + WindowRegistrations.Target( + "mcp", + ai.rever.boss.mcp.McpToolRegistryImpl::registerProvider, + ai.rever.boss.mcp.McpToolRegistryImpl::unregisterProvider, + ) + private val panelMenus = + WindowRegistrations.Target( + "panelMenu", + ai.rever.boss.components.plugin.registries.PanelMenuRegistryImpl::register, + ai.rever.boss.components.plugin.registries.PanelMenuRegistryImpl::unregister, + ) + private val settingsPages = + WindowRegistrations.Target( + "settingsPage", + ai.rever.boss.components.plugin.registries.SettingsPageRegistryImpl::register, + ai.rever.boss.components.plugin.registries.SettingsPageRegistryImpl::unregister, + ) + private val deepLinkActions = + WindowRegistrations.Target( + "deepLinkAction", + ai.rever.boss.components.plugin.registries.DeepLinkActionRegistryImpl::register, + ai.rever.boss.components.plugin.registries.DeepLinkActionRegistryImpl::unregister, + ) + private val shortcutActions = + WindowRegistrations.Target( + "shortcutAction", + ai.rever.boss.components.plugin.registries.PluginShortcutRegistryImpl::register, + ai.rever.boss.components.plugin.registries.PluginShortcutRegistryImpl::unregister, + ) + private val statusBarItems = + WindowRegistrations.Target( + "statusBarItem", + ai.rever.boss.components.plugin.registries.StatusBarRegistryImpl::register, + ai.rever.boss.components.plugin.registries.StatusBarRegistryImpl::unregister, + ) + // Persisted plugins loading state @Volatile private var persistedPluginsLoaded = false @@ -637,7 +689,7 @@ class DefaultPlugin( * Plugins can implement SearchProvider to contribute results to Spotlight. */ override fun registerSearchProvider(provider: SearchProvider) { - SearchRegistryImpl.registerProvider(provider) + registrations.register(searchProviders, provider.providerId, owner = registrationOwner, value = provider) logger.debug( LogCategory.SYSTEM, "Search provider registered", @@ -651,12 +703,13 @@ class DefaultPlugin( * Unregister a search provider. */ override fun unregisterSearchProvider(providerId: String) { - SearchRegistryImpl.unregisterProvider(providerId) + val outcome = registrations.unregister(searchProviders, providerId, owner = registrationOwner) logger.debug( LogCategory.SYSTEM, "Search provider unregistered", mapOf( "providerId" to providerId, + "outcome" to outcome.name, ), ) } @@ -668,8 +721,7 @@ class DefaultPlugin( // ============================================================ override fun registerMcpToolProvider(provider: ai.rever.boss.plugin.api.McpToolProvider) { - ai.rever.boss.mcp.McpToolRegistryImpl - .registerProvider(provider) + registrations.register(mcpToolProviders, provider.providerId, owner = registrationOwner, value = provider) logger.debug( LogCategory.SYSTEM, "MCP tool provider registered", @@ -680,13 +732,13 @@ class DefaultPlugin( } override fun unregisterMcpToolProvider(providerId: String) { - ai.rever.boss.mcp.McpToolRegistryImpl - .unregisterProvider(providerId) + val outcome = registrations.unregister(mcpToolProviders, providerId, owner = registrationOwner) logger.debug( LogCategory.SYSTEM, "MCP tool provider unregistered", mapOf( "providerId" to providerId, + "outcome" to outcome.name, ), ) } @@ -703,53 +755,43 @@ class DefaultPlugin( // ============================================================ override fun registerPanelMenuContribution(contribution: ai.rever.boss.plugin.api.PanelMenuContribution) { - ai.rever.boss.components.plugin.registries.PanelMenuRegistryImpl - .register(contribution) + registrations.register(panelMenus, contribution.contributionId, owner = registrationOwner, value = contribution) } override fun unregisterPanelMenuContribution(contributionId: String) { - ai.rever.boss.components.plugin.registries.PanelMenuRegistryImpl - .unregister(contributionId) + registrations.unregister(panelMenus, contributionId, owner = registrationOwner) } override fun registerSettingsPage(provider: ai.rever.boss.plugin.api.SettingsPageProvider) { - ai.rever.boss.components.plugin.registries.SettingsPageRegistryImpl - .register(provider) + registrations.register(settingsPages, provider.pageId, owner = registrationOwner, value = provider) } override fun unregisterSettingsPage(pageId: String) { - ai.rever.boss.components.plugin.registries.SettingsPageRegistryImpl - .unregister(pageId) + registrations.unregister(settingsPages, pageId, owner = registrationOwner) } override fun registerDeepLinkActionHandler(handler: ai.rever.boss.plugin.api.DeepLinkActionHandler) { - ai.rever.boss.components.plugin.registries.DeepLinkActionRegistryImpl - .register(handler) + registrations.register(deepLinkActions, handler.handlerId, owner = registrationOwner, value = handler) } override fun unregisterDeepLinkActionHandler(handlerId: String) { - ai.rever.boss.components.plugin.registries.DeepLinkActionRegistryImpl - .unregister(handlerId) + registrations.unregister(deepLinkActions, handlerId, owner = registrationOwner) } override fun registerShortcutActionProvider(provider: ai.rever.boss.plugin.api.ShortcutActionProvider) { - ai.rever.boss.components.plugin.registries.PluginShortcutRegistryImpl - .register(provider) + registrations.register(shortcutActions, provider.providerId, owner = registrationOwner, value = provider) } override fun unregisterShortcutActionProvider(providerId: String) { - ai.rever.boss.components.plugin.registries.PluginShortcutRegistryImpl - .unregister(providerId) + registrations.unregister(shortcutActions, providerId, owner = registrationOwner) } override fun registerStatusBarItem(provider: ai.rever.boss.plugin.api.StatusBarItemProvider) { - ai.rever.boss.components.plugin.registries.StatusBarRegistryImpl - .register(provider) + registrations.register(statusBarItems, provider.itemId, owner = registrationOwner, value = provider) } override fun unregisterStatusBarItem(itemId: String) { - ai.rever.boss.components.plugin.registries.StatusBarRegistryImpl - .unregister(itemId) + registrations.unregister(statusBarItems, itemId, owner = registrationOwner) } // Split view operations for plugins that need tab/panel operations @@ -1180,6 +1222,9 @@ class DefaultPlugin( dynamicPluginManager.disposeWindow() sandboxManager.dispose() } + // After the teardown above, so it only catches what a plugin's teardown did not remove: none of it + // may be served again when another window later lets go of the same id. + registrations.release(registrationOwner) // Providers that registered themselves with a process-wide singleton, or that own a // coroutine, do not go away with `pluginScope` - it is not their scope. Only the ones // actually built: see [logDataProviderDelegate]. diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/WindowRegistrations.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/WindowRegistrations.kt new file mode 100644 index 0000000000..f28fd0479e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/WindowRegistrations.kt @@ -0,0 +1,165 @@ +package ai.rever.boss.components.plugin + +import java.util.concurrent.ConcurrentHashMap + +/** + * Keeps a plugin's process-wide registrations alive for as long as any window still holds them. + * + * Every BOSS window owns a [DefaultPlugin] and a `DynamicPluginManager`, and loads its own copy of + * every plugin. Seven kinds of registration are not per window: MCP tool providers, search providers, + * panel menus, settings pages, deep-link actions, shortcut providers and status-bar items all go to + * `object` registries keyed only by id. So a second window's copy replaced the first window's entry, + * and closing either window - `disposeWindow` force-uninstalls every plugin, and each teardown + * unregisters by id - removed the id for the whole app while the other window still had the plugin + * loaded. Nothing registered it again. In the released app an attached agent lost every plugin MCP + * tool (29 tools, then 16) the moment a second window was closed. + * + * Each (registry, id) keeps the windows that registered it, most recent last: + * + * - **register** records this window's value on top and publishes it, which is what the registry + * did before: the newest registration serves. + * - **unregister** removes only this window's entry. When that entry was the one being served and + * another window still has one, that one is published again (a replace, so a registry observer + * never sees the id missing); when none is left, the id is withdrawn as before. A window with no + * entry changes nothing, so it can no longer remove another window's registration. + * + * **Locked per (registry, id), never globally.** Publishing runs plugin code - the MCP registry calls + * the provider's `tools()`, the shortcut registry its `shortcuts()` - and `McpToolRegistryCore` + * keeps untrusted plugin code away from any lock another plugin's lifecycle waits on. A per-id lock + * can only make the same id in another window wait. Restore re-queries tools()/shortcuts() on the + * closing thread; a slow surviving provider can therefore delay closing a window that shared its id. + * Release fences future registrations and visits only slots admitted by that owner. + * + * Not addressed here: while two windows are open, the most recent window's copy serves every window, + * exactly as before. A provider whose action looks a window up in its own plugin state (a shortcut's + * `onAction(actionId, windowId)`) still sees only its own copy's windows. + */ +internal class WindowRegistrations { + /** A window lifetime token; no process-wide set retains closed windows. */ + class Owner { + @Volatile + var released: Boolean = false + private set + + private val admitted = mutableSetOf>() + + internal fun admit(slot: Slot<*>): Boolean = + synchronized(this) { + if (released) return@synchronized false + admitted += slot + true + } + + internal fun forget(slot: Slot<*>) { + synchronized(this) { admitted.remove(slot) } + } + + internal fun close(): List> = + synchronized(this) { + released = true + admitted.toList().also { admitted.clear() } + } + } + + /** One process-wide registry, as [DefaultPlugin] reaches it. */ + class Target( + val name: String, + val publish: (V) -> Unit, + val withdraw: (id: String) -> Unit, + ) + + /** What an [unregister] did to the registry, for logging and tests. */ + enum class Outcome { + /** The window had no entry for the id: the registry is untouched. */ + NOT_REGISTERED_BY_WINDOW, + + /** The window's entry was not the one being served: the registry is untouched. */ + REMOVED_UNSERVED, + + /** Another window's entry is served again. */ + RESTORED_OTHER_WINDOW, + + /** No window holds the id any more: it was removed from the registry. */ + WITHDRAWN, + } + + internal class Slot( + private val target: Target, + private val id: String, + ) { + /** (window, value), the served one last. Guarded by this slot's monitor. */ + private val entries = ArrayList>() + + fun register( + owner: Owner, + value: V, + ) = synchronized(this) { + if (!owner.admit(this)) return@synchronized + entries.removeAll { it.first === owner } + entries += owner to value + target.publish(value) + } + + fun unregister(owner: Owner): Outcome = + synchronized(this) { + val index = entries.indexOfFirst { it.first === owner } + if (index >= 0) owner.forget(this) + val served = index == entries.lastIndex + when { + index < 0 -> { + Outcome.NOT_REGISTERED_BY_WINDOW + } + + !served -> { + entries.removeAt(index) + Outcome.REMOVED_UNSERVED + } + + else -> { + entries.removeAt(index) + val next = entries.lastOrNull() + if (next == null) { + target.withdraw(id) + Outcome.WITHDRAWN + } else { + target.publish(next.second) + Outcome.RESTORED_OTHER_WINDOW + } + } + } + } + } + + private val slots = ConcurrentHashMap, String>, Slot<*>>() + + @Suppress("UNCHECKED_CAST") + private fun slot( + target: Target, + id: String, + ): Slot = slots.computeIfAbsent(target to id) { Slot(target, id) } as Slot + + /** Records [value] as [owner]'s registration of [id] in [target] and publishes it. */ + fun register( + target: Target, + id: String, + owner: Owner, + value: V, + ) = slot(target, id).register(owner, value) + + /** Removes [owner]'s registration of [id] from [target]; see the class KDoc for what is published. */ + fun unregister( + target: Target, + id: String, + owner: Owner, + ): Outcome = slot(target, id).unregister(owner) + + /** + * Removes everything [owner] still holds, as if it had unregistered each one. + * + * For a window that is gone: a registration its plugins' teardown did not remove must not be + * served again later, when a remaining window lets go of the same id. + */ + fun release(owner: Owner) { + owner.close().forEach { it.unregister(owner) } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/PluginRegistrationsAcrossWindowsTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/PluginRegistrationsAcrossWindowsTest.kt new file mode 100644 index 0000000000..86ff8e8ba6 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/PluginRegistrationsAcrossWindowsTest.kt @@ -0,0 +1,380 @@ +package ai.rever.boss.components.plugin + +import ai.rever.boss.components.plugin.registries.DeepLinkActionRegistryImpl +import ai.rever.boss.components.plugin.registries.PanelMenuRegistryImpl +import ai.rever.boss.components.plugin.registries.PluginShortcutRegistryImpl +import ai.rever.boss.components.plugin.registries.SettingsPageRegistryImpl +import ai.rever.boss.components.plugin.registries.StatusBarRegistryImpl +import ai.rever.boss.mcp.McpToolRegistryImpl +import ai.rever.boss.plugin.api.DeepLinkActionHandler +import ai.rever.boss.plugin.api.McpToolDefinition +import ai.rever.boss.plugin.api.McpToolHandler +import ai.rever.boss.plugin.api.McpToolProvider +import ai.rever.boss.plugin.api.McpToolResult +import ai.rever.boss.plugin.api.PanelId +import ai.rever.boss.plugin.api.PanelMenuContribution +import ai.rever.boss.plugin.api.PanelMenuItem +import ai.rever.boss.plugin.api.PanelRegistry +import ai.rever.boss.plugin.api.PluginContext +import ai.rever.boss.plugin.api.PluginSearchResult +import ai.rever.boss.plugin.api.PluginShortcutSpec +import ai.rever.boss.plugin.api.SearchProvider +import ai.rever.boss.plugin.api.SettingsPageProvider +import ai.rever.boss.plugin.api.ShortcutActionProvider +import ai.rever.boss.plugin.api.StatusBarItemProvider +import ai.rever.boss.plugin.api.TabRegistry +import ai.rever.boss.plugin.sandbox.InProcessPluginSandbox +import ai.rever.boss.plugin.sandbox.context.SandboxedPanelRegistry +import ai.rever.boss.plugin.sandbox.context.SandboxedPluginContext +import ai.rever.boss.plugin.sandbox.context.SandboxedTabRegistry +import ai.rever.boss.search.SearchRegistryImpl +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A plugin's app-wide registrations must survive another window closing. + * + * Every BOSS window owns a [DefaultPlugin] and a `DynamicPluginManager`, and loads its own copy of + * every plugin. Seven kinds of registration go to process-wide registries keyed only by id, so the + * second window's copy replaced the first window's entry, and closing either window unregistered + * the id for the whole app while the other window still had the plugin loaded. Measured in the + * released app: an attached agent saw 29 MCP tools, 29 with a second window open, and 16 once that + * window was closed - every plugin tool gone until restart. + * + * Two real [DefaultPlugin] instances stand in for two windows, against the real registries. + */ +class PluginRegistrationsAcrossWindowsTest { + private val nonce = "w${counter.incrementAndGet()}x${System.nanoTime() % 100_000}" + + /** One registration kind: how a window registers it, how it is removed, what the registry holds. */ + private class Kind( + val name: String, + /** Registers a fresh contribution for [id] from [context]; returns a label identifying it. */ + val register: (context: PluginContext, id: String, label: String) -> Unit, + val unregister: (context: PluginContext, id: String) -> Unit, + /** The label of the contribution the registry currently serves for [id], or null. */ + val current: (id: String) -> String?, + ) + + private val kinds = + listOf( + Kind( + "MCP tool provider", + { c, id, label -> c.registerMcpToolProvider(mcpProvider(id, label)) }, + { c, id -> c.unregisterMcpToolProvider(id) }, + { id -> + McpToolRegistryImpl.allTools.value + .firstOrNull { it.definition.name == "tool_$id" } + ?.definition + ?.description + }, + ), + Kind( + "search provider", + { c, id, label -> c.registerSearchProvider(searchProvider(id, label)) }, + { c, id -> c.unregisterSearchProvider(id) }, + { id -> + SearchRegistryImpl.providers.value + .firstOrNull { it.providerId == id } + ?.displayName + }, + ), + Kind( + "panel menu contribution", + { c, id, label -> c.registerPanelMenuContribution(panelMenu(id, label)) }, + { c, id -> c.unregisterPanelMenuContribution(id) }, + { id -> + PanelMenuRegistryImpl.contributions.value[id] + ?.items(PanelId("p", 0)) + ?.single() + ?.label + }, + ), + Kind( + "settings page", + { c, id, label -> c.registerSettingsPage(settingsPage(id, label)) }, + { c, id -> c.unregisterSettingsPage(id) }, + { id -> SettingsPageRegistryImpl.pages.value[id]?.displayName }, + ), + Kind( + "deep-link action handler", + { c, id, label -> c.registerDeepLinkActionHandler(deepLinkHandler(id, label)) }, + { c, id -> c.unregisterDeepLinkActionHandler(id) }, + { id -> (DeepLinkActionRegistryImpl.handlers.value[id] as? Labelled)?.label }, + ), + Kind( + "shortcut action provider", + { c, id, label -> c.registerShortcutActionProvider(shortcutProvider(id, label)) }, + { c, id -> c.unregisterShortcutActionProvider(id) }, + { id -> + PluginShortcutRegistryImpl.shortcuts.value + .firstOrNull { it.providerId == id } + ?.spec + ?.displayName + }, + ), + Kind( + "status bar item", + { c, id, label -> c.registerStatusBarItem(statusBarItem(id, label)) }, + { c, id -> c.unregisterStatusBarItem(id) }, + { id -> (StatusBarRegistryImpl.items.value[id] as? Labelled)?.label }, + ), + ) + + private fun idFor(kind: Kind) = "reg_${nonce}_${kinds.indexOf(kind)}" + + /** A window's [DefaultPlugin], without a project. */ + private fun window() = DefaultPlugin(PanelRegistry(), TabRegistry(), windowProjectState = null) + + /** Runs [block] with two windows, and leaves the registries and windows as it found them. */ + private fun withTwoWindows(block: (DefaultPlugin, DefaultPlugin) -> Unit) { + val first = window() + val second = window() + try { + block(first, second) + } finally { + for (kind in kinds) { + runCatching { kind.unregister(second, idFor(kind)) } + runCatching { kind.unregister(first, idFor(kind)) } + } + runCatching { second.dispose() } + runCatching { first.dispose() } + } + } + + /** Every kind is checked before failing, so a failure names all the kinds it applies to. */ + private fun assertEveryKind( + kinds: List, + expectation: (Kind) -> String?, + ) { + val wrong = kinds.mapNotNull { kind -> expectation(kind)?.let { "${kind.name}: $it" } } + assertEquals(emptyList(), wrong) + } + + private fun expect( + expected: String?, + kind: Kind, + ): String? { + val actual = kind.current(idFor(kind)) + return if (actual == expected) null else "expected $expected, registry serves $actual" + } + + @Test + fun `closing the newer window keeps the older window's registration`() = + withTwoWindows { first, second -> + assertEveryKind(kinds) { kind -> + kind.register(first, idFor(kind), "first") + kind.register(second, idFor(kind), "second") + expect("second", kind) ?: run { + kind.unregister(second, idFor(kind)) + expect("first", kind) + } + } + } + + @Test + fun `closing the older window keeps the newer window's registration`() = + withTwoWindows { first, second -> + assertEveryKind(kinds) { kind -> + kind.register(first, idFor(kind), "first") + kind.register(second, idFor(kind), "second") + kind.unregister(first, idFor(kind)) + expect("second", kind) + } + } + + @Test + fun `the last window to unregister removes the registration`() = + withTwoWindows { first, second -> + assertEveryKind(kinds) { kind -> + kind.register(first, idFor(kind), "first") + kind.register(second, idFor(kind), "second") + kind.unregister(second, idFor(kind)) + kind.unregister(first, idFor(kind)) + expect(null, kind) + } + } + + @Test + fun `a window that never registered an id cannot remove another window's`() = + withTwoWindows { first, second -> + assertEveryKind(kinds) { kind -> + kind.register(first, idFor(kind), "first") + kind.unregister(second, idFor(kind)) + expect("first", kind) + } + } + + @Test + fun `a closed window's plugin teardown keeps the other window's registrations`() { + // What closing a window runs: DefaultPlugin.dispose -> disposeWindow -> uninstallPlugin(force) + // -> TrackingPluginContext.unregisterAll, through the sandbox wrapper, per loaded plugin. + // Search is left out here: the wrappers do not forward it on this base (#670). + val teardownKinds = kinds.filter { it.name != "search provider" } + withTwoWindows { first, second -> + val pluginId = "plugin.$nonce" + val firstContext = pluginContext(pluginId, first) + val secondContext = pluginContext(pluginId, second) + for (kind in teardownKinds) { + kind.register(firstContext, idFor(kind), "first") + kind.register(secondContext, idFor(kind), "second") + } + + secondContext.unregisterAll() + + assertEveryKind(teardownKinds) { kind -> expect("first", kind) } + } + } + + @Test + fun `disposing a window releases registrations its plugins' teardown left behind`() { + // A registration no tracking context recorded, or whose teardown threw, must not keep a closed + // window's copy served - nor come back later when the surviving window lets go. + val first = window() + val second = window() + try { + for (kind in kinds) { + kind.register(first, idFor(kind), "first") + kind.register(second, idFor(kind), "second") + } + + second.dispose() + // A startup registration finishing after disposal cannot resurrect this window. + for (kind in kinds) kind.register(second, idFor(kind), "late-second") + + assertEveryKind(kinds) { kind -> expect("first", kind) } + } finally { + for (kind in kinds) runCatching { kind.unregister(first, idFor(kind)) } + runCatching { first.dispose() } + } + } + + private fun pluginContext( + pluginId: String, + window: DefaultPlugin, + ): TrackingPluginContext { + val sandbox = InProcessPluginSandbox(pluginId) + val sandboxed = + SandboxedPluginContext( + _sandbox = sandbox, + delegate = window, + sandboxedPanelRegistry = SandboxedPanelRegistry(sandbox, window.panelRegistry), + sandboxedTabRegistry = SandboxedTabRegistry(sandbox, window.tabRegistry), + ) + return TrackingPluginContext(pluginId = pluginId, delegate = sandboxed, tracker = PluginRegistrationTracker()) + } + + private interface Labelled { + val label: String + } + + private companion object { + val counter = AtomicInteger() + + fun mcpProvider( + id: String, + label: String, + ): McpToolProvider = + object : McpToolProvider { + override val providerId = id + + override fun tools() = + listOf( + McpToolDefinition( + name = "tool_$id", + description = label, + handler = McpToolHandler { McpToolResult(label) }, + ), + ) + } + + fun searchProvider( + id: String, + label: String, + ): SearchProvider = + object : SearchProvider { + override val providerId = id + override val displayName = label + + override suspend fun search( + query: String, + limit: Int, + ): List = emptyList() + } + + fun panelMenu( + id: String, + label: String, + ): PanelMenuContribution = + object : PanelMenuContribution { + override val contributionId = id + + override fun items(panelId: PanelId) = listOf(PanelMenuItem(id = "item", label = label)) + + override fun onItemClick( + panelId: PanelId, + itemId: String, + windowId: String?, + ) = Unit + } + + fun settingsPage( + id: String, + label: String, + ): SettingsPageProvider = + object : SettingsPageProvider { + override val pageId = id + override val displayName = label + override val description = label + override val icon = Icons.Default.Settings + + @Composable + override fun Content() = Unit + } + + fun deepLinkHandler( + id: String, + label: String, + ): DeepLinkActionHandler = + object : DeepLinkActionHandler, Labelled { + override val handlerId = id + override val label = label + + override fun handle( + action: String, + params: Map, + ) = false + } + + fun shortcutProvider( + id: String, + label: String, + ): ShortcutActionProvider = + object : ShortcutActionProvider { + override val providerId = id + + override fun shortcuts() = listOf(PluginShortcutSpec("plugin.$id.act", label, label)) + + override fun onAction( + actionId: String, + windowId: String?, + ) = Unit + } + + fun statusBarItem( + id: String, + label: String, + ): StatusBarItemProvider = + object : StatusBarItemProvider, Labelled { + override val itemId = id + override val label = label + + @Composable + override fun Content() = Unit + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/WindowRegistrationsTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/WindowRegistrationsTest.kt new file mode 100644 index 0000000000..4b9b0fa6b4 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/WindowRegistrationsTest.kt @@ -0,0 +1,261 @@ +package ai.rever.boss.components.plugin + +import ai.rever.boss.components.plugin.WindowRegistrations.Outcome +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The arbitration rules of [WindowRegistrations], against a recording registry. + * + * [PluginRegistrationsAcrossWindowsTest] covers the same behaviour through two real `DefaultPlugin` + * windows and the real registries; this pins the ordering cases a pair of windows cannot reach. + */ +class WindowRegistrationsTest { + /** A registry that serves one value per id, like the real ones, and records every call. */ + private class RecordingRegistry( + name: String = "recording", + ) { + val served = mutableMapOf() + val calls = mutableListOf() + val target = + WindowRegistrations.Target>( + name = name, + publish = { (id, value) -> + served[id] = value + calls += "publish $id=$value" + }, + withdraw = { id -> + served.remove(id) + calls += "withdraw $id" + }, + ) + } + + private val registry = RecordingRegistry() + private val registrations = WindowRegistrations() + private val window1 = WindowRegistrations.Owner() + private val window2 = WindowRegistrations.Owner() + private val window3 = WindowRegistrations.Owner() + + private fun register( + window: WindowRegistrations.Owner, + value: String, + id: String = "tools", + ) = registrations.register(registry.target, id, window, id to value) + + private fun unregister( + window: WindowRegistrations.Owner, + id: String = "tools", + ) = registrations.unregister(registry.target, id, window) + + @Test + fun `the newest registration is served`() { + register(window1, "one") + register(window2, "two") + + assertEquals("two", registry.served["tools"]) + } + + @Test + fun `when the served window lets go, the previous window's registration is served again`() { + register(window1, "one") + register(window2, "two") + + assertEquals(Outcome.RESTORED_OTHER_WINDOW, unregister(window2)) + assertEquals("one", registry.served["tools"]) + assertEquals(listOf("publish tools=one", "publish tools=two", "publish tools=one"), registry.calls) + } + + @Test + fun `when an unserved window lets go, the registry is not touched`() { + register(window1, "one") + register(window2, "two") + registry.calls.clear() + + assertEquals(Outcome.REMOVED_UNSERVED, unregister(window1)) + assertEquals("two", registry.served["tools"]) + assertEquals(emptyList(), registry.calls) + } + + @Test + fun `the last window to let go withdraws the id`() { + register(window1, "one") + register(window2, "two") + unregister(window1) + + assertEquals(Outcome.WITHDRAWN, unregister(window2)) + assertEquals(null, registry.served["tools"]) + } + + @Test + fun `a window with no registration changes nothing`() { + register(window1, "one") + registry.calls.clear() + + assertEquals(Outcome.NOT_REGISTERED_BY_WINDOW, unregister(window2)) + assertEquals("one", registry.served["tools"]) + assertEquals(emptyList(), registry.calls) + } + + @Test + fun `registering again from the same window replaces its entry and serves it`() { + // A plugin reloaded in window 1 while window 2 also has it: the reload is the newest. + register(window1, "one") + register(window2, "two") + register(window1, "one-reloaded") + + assertEquals("one-reloaded", registry.served["tools"]) + assertEquals(Outcome.RESTORED_OTHER_WINDOW, unregister(window1)) + assertEquals("two", registry.served["tools"]) + assertEquals(Outcome.WITHDRAWN, unregister(window2)) + } + + @Test + fun `three windows closing out of order always serve the newest remaining registration`() { + register(window1, "one") + register(window2, "two") + register(window3, "three") + + unregister(window2) + assertEquals("three", registry.served["tools"]) + unregister(window3) + assertEquals("one", registry.served["tools"]) + unregister(window1) + assertEquals(null, registry.served["tools"]) + } + + @Test + fun `when the served window lets go of several, the newest remaining one is served`() { + register(window1, "one") + register(window2, "two") + register(window3, "three") + + assertEquals(Outcome.RESTORED_OTHER_WINDOW, unregister(window3)) + assertEquals("two", registry.served["tools"]) + } + + @Test + fun `ids are arbitrated independently`() { + register(window1, "one", id = "a") + register(window2, "two", id = "b") + + assertEquals(Outcome.NOT_REGISTERED_BY_WINDOW, unregister(window2, id = "a")) + assertEquals(Outcome.WITHDRAWN, unregister(window2, id = "b")) + assertEquals(mapOf("a" to "one"), registry.served) + } + + @Test + fun `the same id in two registries is arbitrated separately`() { + // Plugins reuse one id across registries: the Toolbox registers its MCP provider and its + // deep-link handler under its plugin id. + val other = RecordingRegistry(name = "other") + registrations.register(registry.target, "shared", window1, "shared" to "tools") + registrations.register(other.target, "shared", window2, "shared" to "links") + + assertEquals(Outcome.NOT_REGISTERED_BY_WINDOW, registrations.unregister(registry.target, "shared", window2)) + assertEquals(mapOf("shared" to "tools"), registry.served) + assertEquals(mapOf("shared" to "links"), other.served) + } + + @Test + fun `releasing a closed window drops everything it still held`() { + register(window1, "one", id = "a") + register(window2, "two", id = "a") + register(window2, "two", id = "b") + + registrations.release(window2) + + assertEquals(mapOf("a" to "one"), registry.served) + assertEquals(Outcome.NOT_REGISTERED_BY_WINDOW, unregister(window2, id = "a")) + // And a released window's entry can never come back once the survivor lets go. + assertEquals(Outcome.WITHDRAWN, unregister(window1, id = "a")) + } + + @Test + fun `releasing an unrelated window does not wait for blocked plugin publication`() { + val entered = CountDownLatch(1) + val resume = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + val target = + WindowRegistrations.Target( + "blocking", + publish = { value -> + if (value == "one") { + entered.countDown() + check(resume.await(10, TimeUnit.SECONDS)) + } + }, + withdraw = {}, + ) + // Previously owning an id must not keep a dependency on it after unregister. + registrations.register(target, "blocked", window2, "previous") + registrations.unregister(target, "blocked", window2) + try { + val publisher = executor.submit { registrations.register(target, "blocked", window1, "one") } + assertTrue(entered.await(5, TimeUnit.SECONDS)) + val release = executor.submit { registrations.release(window2) } + // The callback is still blocked. This window never owned that slot, so its disposal + // must finish without waiting for another window's plugin to return. + release.get(2, TimeUnit.SECONDS) + resume.countDown() + publisher.get(5, TimeUnit.SECONDS) + } finally { + resume.countDown() + executor.shutdownNow() + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) + } + } + + @Test + fun `a released window cannot replace the survivor or introduce a new id`() { + register(window1, "one") + register(window2, "two") + registrations.release(window2) + register(window2, "late") + register(window2, "late-new", id = "new") + assertEquals(mapOf("tools" to "one"), registry.served) + registrations.release(window1) + assertEquals(emptyMap(), registry.served) + } + + @Test + fun `release fences new registrations while an admitted publication finishes`() { + val entered = CountDownLatch(1) + val resume = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + val target = + WindowRegistrations.Target>( + "blocking", + publish = { value -> + if (value.second == "two") { + entered.countDown() + check(resume.await(10, TimeUnit.SECONDS)) + } + registry.target.publish(value) + }, + withdraw = registry.target.withdraw, + ) + registrations.register(target, "tools", window1, "tools" to "one") + try { + val publisher = executor.submit { registrations.register(target, "tools", window2, "tools" to "two") } + assertTrue(entered.await(5, TimeUnit.SECONDS)) + val release = executor.submit { registrations.release(window2) } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (!window2.released && System.nanoTime() < deadline) Thread.yield() + assertTrue(window2.released) + registrations.register(target, "new", window2, "new" to "late") + resume.countDown() + publisher.get(5, TimeUnit.SECONDS) + release.get(5, TimeUnit.SECONDS) + assertEquals(mapOf("tools" to "one"), registry.served) + } finally { + resume.countDown() + executor.shutdownNow() + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) + } + } +} From 5f458b2b0e9d85479bf11056c62d1f1023d2df1f Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 15 Sep 2026 03:52:29 -0700 Subject: [PATCH 48/49] test(filesystem): cover Linux ARM64 and report RPC failures --- .github/workflows/filesystem-limits.yml | 2 +- .../rever/boss/service/filesystem/RpcFailureDiagnostics.kt | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/filesystem-limits.yml b/.github/workflows/filesystem-limits.yml index ee8b0521d6..f21f61f478 100644 --- a/.github/workflows/filesystem-limits.yml +++ b/.github/workflows/filesystem-limits.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/RpcFailureDiagnostics.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/RpcFailureDiagnostics.kt index 57093d9d8c..d266272ad0 100644 --- a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/RpcFailureDiagnostics.kt +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/RpcFailureDiagnostics.kt @@ -6,9 +6,12 @@ import io.grpc.ServerCall import io.grpc.ServerCallHandler import io.grpc.ServerInterceptor import io.grpc.Status +import org.slf4j.LoggerFactory /** Keep the actual server exception in CI reports; a client receives only UNKNOWN for unexpected IO. */ internal object RpcFailureDiagnostics : ServerInterceptor { + private val logger = LoggerFactory.getLogger(RpcFailureDiagnostics::class.java) + override fun interceptCall( call: ServerCall, headers: Metadata, @@ -20,7 +23,7 @@ internal object RpcFailureDiagnostics : ServerInterceptor { status: Status, trailers: Metadata, ) { - if (status.code == Status.Code.UNKNOWN) status.cause?.printStackTrace() + if (!status.isOk) logger.warn("Filesystem test RPC closed with {}", status, status.cause) super.close(status, trailers) } }, From 8409e8039784df51696f89ba5a15063a73aa1fdc Mon Sep 17 00:00:00 2001 From: Raghav Bansal <273537979+Raghav-Bansal-15@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:46:19 +0530 Subject: [PATCH 49/49] fix: validate resolved directory scan roots --- .../filesystem/BoundedDirectoryScan.kt | 2 ++ .../filesystem/FileSystemServiceImpl.kt | 3 ++- .../filesystem/FileSystemLimitsTest.kt | 19 ++++++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/BoundedDirectoryScan.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/BoundedDirectoryScan.kt index 537ac30ffb..be09b1890c 100644 --- a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/BoundedDirectoryScan.kt +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/BoundedDirectoryScan.kt @@ -13,6 +13,7 @@ import kotlin.coroutines.CoroutineContext internal class BoundedDirectoryScan( private val request: ScanDirectoryRequest, private val context: CoroutineContext, + private val validateTarget: (String) -> Unit, ) { private val requestedDepth = if (!request.recursive) 1 else request.maxDepth.takeIf { it > 0 } ?: Int.MAX_VALUE private val depth = requestedDepth.coerceAtMost(FileSystemLimits.SCAN_DEPTH) @@ -23,6 +24,7 @@ internal class BoundedDirectoryScan( fun scan(root: Path): List { val target = root.toRealPath() + validateTarget(target.toString()) Files.walkFileTree( target, emptySet(), diff --git a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt index 82e645ac54..1771b72b1d 100644 --- a/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt +++ b/modules/boss-service-filesystem/src/main/kotlin/ai/rever/boss/service/filesystem/FileSystemServiceImpl.kt @@ -57,7 +57,8 @@ class FileSystemServiceImpl : FileSystemServiceGrpcKt.FileSystemServiceCoroutine .build() } - val entries = BoundedDirectoryScan(request, currentCoroutineContext()).scan(dir.toPath().toAbsolutePath()) + val scanner = BoundedDirectoryScan(request, currentCoroutineContext(), ::validatePath) + val entries = scanner.scan(dir.toPath().toAbsolutePath()) ScanDirectoryResponse .newBuilder() diff --git a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt index 647736d05f..fa0325f2fd 100644 --- a/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt +++ b/modules/boss-service-filesystem/src/test/kotlin/ai/rever/boss/service/filesystem/FileSystemLimitsTest.kt @@ -54,9 +54,26 @@ class FileSystemLimitsTest { fun cleanup() { channel.shutdownNow() server.shutdownNow() - root.toFile().deleteRecursively() + // Files.walk does not follow symlinks; fixture aliases must never widen cleanup. + Files.walk(root).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } } + @Test + fun `scan refuses an alias into a blocked system root`() = + runBlocking { + org.junit.Assume.assumeTrue(Platform.isLinux()) + val alias = Files.createSymbolicLink(root.resolve("system-alias"), Path.of("/proc")) + try { + assertFailsWith { + stub.scanDirectory(ScanDirectoryRequest.newBuilder().setPath(alias.toString()).build()) + } + } finally { + Files.deleteIfExists(alias) + } + } + @Test fun `native POSIX open refuses a replaced leaf link`() { if (Platform.isWindows()) return