Skip to content

fix(cache): keep cache refusals from blocking verified installs - #565

Merged
kshivang merged 39 commits into
risa-labs-inc:devfrom
Raghav-Bansal-15:fix/security-plugin-cache
Sep 15, 2026
Merged

kshivang merged 39 commits into
risa-labs-inc:devfrom
Raghav-Bansal-15:fix/security-plugin-cache

Conversation

@Raghav-Bansal-15

@Raghav-Bansal-15 Raghav-Bansal-15 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

A verified cache entry could disappear between lookup and copy, failing an install instead of downloading it.

Fall back to a fresh verified download when the cache copy is unavailable. Signature enforcement remains outside the best-effort cache wrapper.

Validation at d3731ec9: 71 repository tests and ktlint/detekt pass locally. The new race regression fails with the unprotected copy and passes with the fallback.

Original and reviewer commits are preserved. Upstream workflow approval and required checks are separate gates; no merge or release.

Hosted validation: all current-head fork workflows passed at d3731ec9: 🔨 Build & Test. Upstream approvals and the stated non-CI gates remain separate.

@kshivang

Copy link
Copy Markdown
Contributor

@claude review the current plugin cache identity and linked-path hardening, including callers and regression coverage.

@claude

claude Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 5m 15s —— View job


Review: plugin cache identity + linked-path hardening

  • Read the diff against origin/dev
  • Audit the new identity keying and linked-path checks
  • Trace every caller for the new throwing contract
  • Assess regression coverage and CI portability

The core idea is right and the escape surface really is closed: every path component is now a SHA-256 of the exact input, so no id/version can alias another or steer the path, every stat and traversal uses NOFOLLOW_LINKS, Files.walk never follows a link, deleteEntries deletes the link rather than its target, and cacheJar publishes through ATOMIC_MOVE so a planted entry is replaced rather than written through. The two symlink tests are genuine mutation tests - drop either check and they delete the surrounding temp tree.

What I'd want changed is the failure mode, not the checks. Three of them turn a cache anomaly into a hard install failure, and the recovery path is not reachable from the app.


High

1. A planted link at the entry path permanently blocks installing that plugin/version, and nothing can clear it

cacheFile throws IllegalStateException (PluginDownloadCache.kt:189) for a symlinked entry, and pluginDirectory throws for a symlinked plugin dir (:178). Both are on the path of getCachedJar, cacheJar and removeCachedJar. In the caller, downloadPlugin wraps everything in runCatching (RemotePluginRepository.kt:317), so that ISE comes back as Result.failure - the download is never attempted, and the purge API that would clean it (removeCachedJar, wired as onVerificationFailure at RemotePluginRepository.kt:349) throws from the same cacheFile call.

The only thing that clears it is clearCache(), which has no production caller - PluginStoreSetup.clearCache (:432) and getCacheStats (:439) are unreferenced anywhere in composeApp. So the recovery is "delete ~/.boss/plugin-cache by hand".

Worth noting the throw isn't what provides the safety here. With the check at :189 removed, getCachedJar is still safe, because Files.isRegularFile(path, NOFOLLOW_LINKS) is already false for a link (:38), and cacheJar is still safe, because Files.move(..., REPLACE_EXISTING) unlinks the symlink rather than writing through it. A cache's correct answer to a corrupt entry is "miss, and remove it" - same shape as the existing SHA-mismatch branch at :52-56. I'd delete the link and return null in getCachedJar, and let cacheJar proceed into the atomic move.

Fix this →

2. A cache-write failure now fails an already-verified download

downloadCache.cacheJar(...) is called at RemotePluginRepository.kt:438 - after the SHA-256 check, after enforceStoreSignature, and after PluginSignatureSidecar.persist. The jar is complete and trusted at targetPath. Any throw from cacheJar (the new require, either check, or an IOException from createTempFile/move on a full or read-only disk) now discards that work and reports the install as failed. Caching is an optimisation; it should not be able to fail the operation it was optimising. Wrap the call site in its own runCatching and log.

3. The new require on id/version syntax can make a plugin uninstallable, and it isn't load-bearing

cacheKey (:196) rejects anything outside [A-Za-z0-9._+-], longer than 4096 chars, or ending in .. The old code sanitized instead (pluginId.replace(Regex("[^a-zA-Z0-9._-]"), "_")), so those inputs worked. Now a single store row with, say, 1.0.0 (beta), 1.0~rc1, a non-ASCII version, or an empty version string makes getCachedJar throw before the download is ever attempted - downloadPlugin fails outright rather than falling back to a fetch. Nothing between the store row and this call validates either field.

Since the emitted filename is v-<64 hex>.jar / p-<64 hex>, no input can affect path syntax at all - the charset rule, the trailing-dot rule and the 4096 bound are guarding a filename that is already a hash. I'd keep only "non-blank" (hashing "" is meaningless) plus a generous length bound, and drop the charset check. If the project does want a syntax contract on store ids/versions, it belongs where the row is parsed, with an error that says so - not as an IllegalArgumentException from a cache.

The bound-on-metadata-reads justification in the comment at :195 doesn't quite hold either: readMetadata already caps its own read at 16 KiB independently (:140-142).


Medium

4. All BossLogger use was deleted - AGENTS.md mandates BossLogger for this repo, and the removed WARN on Cached JAR SHA-256 mismatch, removing was the only signal that a cache entry had been tampered with. It now deletes silently (:52-56). Same for clearCache/removeAllVersions/cleanOldEntries, and readMetadata's runCatching { ... }.getOrNull() (:135-145) swallows a malformed sidecar, an I/O error and a "cache directory was replaced" check with no trace. For a change whose whole purpose is hardening, I'd restore at least the mismatch, the refusal and the metadata-unreadable paths. The class KDoc and per-method KDoc also went with it, which is a separate readability loss on a public API.

5. rootIdentity is vacuous on Windows - BasicFileAttributes.fileKey() returns null on the Windows provider, so rootIdentity is null at construction and the check at :163 compares null == null and always passes. "Plugin cache directory was replaced" is therefore a POSIX-only guarantee. That's acceptable, but it should say so in a comment, and the KDoc shouldn't imply otherwise. There's also no test for this branch on any platform.

6. Legacy entries are, in practice, never reclaimed - and they skew the stats - the comment at :205 says legacy entries "remain removable by clear/expiry", but clearCache has no production caller and cleanOldEntries has none at all (only its own definition and the new test). removeAllVersions can't see them, since it resolves p-<hash>. So every existing install keeps its full pre-change cache on disk forever. Meanwhile getCachedFileCount (:117) does count legacy jars via entries(), while getCachedPluginCount (:114) goes through listCachedPlugins and needs a sidecar - so getCacheStats can report fileCount: 12, pluginCount: 0 for the same directory. A one-time legacy sweep at construction (or a cleanOldEntries call during store init) would close both.

7. The constructor does I/O that can throw, and silently accepts a linked root - Files.createDirectories(cacheDir.toPath()).toRealPath() (:22) runs in the initializer, and RemotePluginRepository takes PluginDownloadCache() as a default argument (RemotePluginRepository.kt:38), so an unwritable ~/.boss now fails construction of the repository rather than degrading. It's caught by PluginStoreSetup.initialize's try/catch, so nothing crashes - but the store ends up with no remote repository at all where it previously had one with a dud cache. Separately, toRealPath() follows a symlinked cache root, so a linked root is silently accepted while a linked subdirectory is refused. That asymmetry is defensible (the root is host-supplied) but deserves one line saying it's intended.


Low / nits

  • :196 compiles a fresh Regex on every call; hoist to a private val (or a companion const-style top-level) if the check survives point 3.
  • Return-count semantics now disagree: deleteEntries counts jars only (:217), while cleanOldEntries counts every expired regular file (:154-155) - two per cached version now that there's a sidecar, so its number is roughly double the old one for the same work.
  • cleanOldEntries no longer prunes the emptied p-<hash> directories; the old walkBottomUp pass did.
  • A SHA-mismatch delete (:55) leaves the .json sidecar orphaned; removeCachedJar removes both, so the two purge paths differ. The orphan then counts toward getCacheSize.
  • readMetadata bounds by readNBytes(16_385) (bytes) but compares text.length (chars) at :142 - harmless, just not the same quantity.
  • readMetadata calls cacheFile per entry (:144), which re-runs checkRoot() + toRealPath() + readAttributes; listCachedPlugins is therefore O(n) syscalls in the root check alone. Fine at current cache sizes.
  • @Synchronized is per-instance. Correct today - PluginStoreSetup:266 builds the single instance and hands it to RemotePluginRepository at :343 - but worth a KDoc note that it is not a cross-process lock (the atomic moves carry that weight) so a future second instance isn't assumed to be serialized.
  • paths.filter { it != directory || includeRoot } (:216) reads oddly when includeRoot is true (the predicate is a constant); if (includeRoot) paths else paths.filter { it != directory } says it plainly.

Regression coverage

What's there is well chosen - the collision/case test, the long-id test, and both symlink tests all fail if you revert the thing they cover. Gaps:

  1. Windows portability of the two symlink tests. ./gradlew build on windows-latest (build.yml:242) runs check → desktopTest, so cleanupDoesNotTraverseLinkedDirectories and refusesSymlinkedEntryAndPluginDirectory execute there. Files.createSymbolicLink needs SeCreateSymbolicLinkPrivilege; GitHub's Windows runners generally have it, but an Assumptions.assumeTrue guard around the link creation is cheap insurance against a red matrix leg on a check that is POSIX-shaped anyway. Also note a Windows junction is not an isSymbolicLink, so the :189 check wouldn't see one (the toRealPath() comparison in checkDirectory would) - untested either way.
  2. No caller-level test. The interaction that actually regresses is downloadPlugin meeting a poisoned entry. RemotePluginRepositoryDownloadTest already exercises both the cache-hit and fresh-download wiring, so a case asserting that a planted link still ends in a successful download belongs there.
  3. "Legacy cache entries can be downloaded again" is unverified. Write an old-layout file (<id>/<id>_<ver>.jar) and assert getCachedJar returns null, and that clearCache/cleanOldEntries reclaim it. That claim is in the PR description and nothing pins it.
  4. No test for the metadata-loss path - delete a sidecar and assert the jar is still served by getCachedJar but absent from listCachedPlugins (i.e. that the desync at point 6 is the intended behaviour, not an accident).
  5. No test for checkRoot's replaced-directory branch (and per point 5 it can't pass meaningfully on Windows).
  6. rejectsPathSyntaxWithoutTouchingOutsideFiles pins the syntax require in place; if point 3 is taken, that test narrows to blank/oversized inputs.

Summary

The security property holds. My blocking concerns are the three High items, all the same shape: hardening that fails closed on the operation rather than closed on the cache. A cache that refuses to serve a suspicious entry is right; a cache that refuses to let the plugin be downloaded at all - and can't be cleared from inside the app - converts an attempted poisoning into a durable denial of install. Then restore the BossLogger calls, since AGENTS.md requires them and the mismatch warning is the tamper signal.
· branch fix/security-plugin-cache

@kshivang

Copy link
Copy Markdown
Contributor

@Raghav-Bansal-15 review handoff after a bounded repair pass. Original authorship and c17c0ac are preserved; fixes are pushed through 0302635 on this PR, based on current dev e1926cc.

The original cache hash/path hardening is useful. I fixed the missing identity-metadata check in lookup (09d42ed). In response to the Claude review, dc89ead/030263583 isolate cache lookup/write/purge failures from the verified download result, preserve cancellation, contain cache initialization failures, restore safety warnings, and remove orphan metadata on SHA mismatch. This addresses the three High findings without relaxing the linked-path checks or the store hash/signature checks.

Not ready: final-head execution and review verification are outstanding. Five agent-added regressions cover mismatched/corrupt/missing/linked metadata, linked cache entries, unsupported version syntax, failed promotion into a nonempty directory, and an unusable cache root. They have only been inspected, not executed. The local JDK 17 validation invocation waited behind the shared build lock and was canceled before Gradle started; no test, ktlint, or detekt success is claimed. git diff --check passes. Please run:

python3 /Users/kshivang/Development/Boss/.pr-review-sessions/with-build-lock.py ./gradlew :plugin-platform:plugin-repository:desktopTest :plugin-platform:plugin-repository:ktlintCheck :plugin-platform:plugin-repository:detekt

Outside this shared review workspace, run the equivalent Gradle tasks directly. Confirm all original cache tests and download signature tests still pass, and validate the symlink cases on Windows/macOS/Linux. Then verify the High findings against the final head. Claude's completed review was on the original implementation, not these repairs; no repeated review request was sent.

Infrastructure versus author findings: the shared local build queue and fork CI approval are maintainer/infrastructure blockers, not demonstrated author test failures. Hosted runs on the preceding repair heads (34736545023 and 34736721522) are action_required. A maintainer must approve the final-head build; do not transfer old-head results. The original author defects were cache anomalies aborting installs and absent metadata lookup enforcement, addressed above but still needing execution proof.

Known follow-up from the Medium feedback: legacy files are not automatically swept and file/plugin statistics can differ until explicit cleanup. Automatic deletion at initialization was not added during this repair pass. Root links are intentionally canonicalized; fileKey replacement detection is provider-dependent, now documented. No live app validation was performed. In a disposable test profile, verify a repeat plugin install, a linked/blocked cache path falling back to a verified download with a warning, and bad signatures still being rejected.

Converting to draft under the review-handoff policy until this validation is completed. No merge or auto-merge was performed.

@kshivang
kshivang marked this pull request as draft September 13, 2026 04:00
@kshivang

Copy link
Copy Markdown
Contributor

@Raghav-Bansal-15 final disposition after the maintainer authorized suitable PRs to merge into dev: this PR remains draft, not merged.

Your exact-identity hashing, atomic cache publication and linked-path tests address a useful cache collision/path boundary. That contribution and your original commit remain credited. The three High findings in the latest Claude review have bounded repairs pushed through 0302635, but execution proof remains missing.

I refreshed dev/main and rechecked the current head. Final-head Build & Test is still action_required with zero executed jobs. The shared local build lock is still occupied. These are infrastructure/maintainer gates, not evidence of failing author tests; no required checks were bypassed.

Next steps: have a maintainer approve the final-head workflow; run the plugin-repository desktopTest, ktlintCheck and detekt tasks listed in the prior handoff; verify the new metadata and caller-fallback regressions plus existing hash/signature rejection tests across supported platforms. Final-head review verification is still needed because the latest Claude review predates the repairs.

Review choices: retained cache syntax/link refusals but isolated them at the caller, so a refused cache no longer blocks a verified download. Rejected the suggestion to serve a JAR with missing metadata because that contradicts the PR's identity-metadata guarantee; a regression now requires a cache miss. Did not preemptively skip Windows symlink tests without an actual privilege failure. Automatic legacy deletion remains a documented follow-up, rather than introducing new startup deletion during the bounded repair pass.

No new repair/review loop started. Draft status and the detailed validation handoff remain in place until these gates are met.

@kshivang
kshivang force-pushed the dev branch 5 times, most recently from cd88490 to 5a2e818 Compare September 14, 2026 11:16
@Raghav-Bansal-15
Raghav-Bansal-15 force-pushed the fix/security-plugin-cache branch from 0302635 to 327d6c2 Compare September 14, 2026 16:13
@Raghav-Bansal-15 Raghav-Bansal-15 changed the title Key plugin cache entries by exact identity and reject linked paths fix(cache): keep cache refusals from blocking verified installs Sep 14, 2026
@Raghav-Bansal-15
Raghav-Bansal-15 marked this pull request as ready for review September 14, 2026 16:13
@Raghav-Bansal-15

Copy link
Copy Markdown
Contributor Author

Review follow-up: Key entries by their exact identity, require matching metadata, and keep cache failures separate from download/signature verification. Retain linked-path refusals without making a cache failure an installation failure.

Validation: 70 repository tests and static analysis pass locally. Latest-head full CI is pending.

The PR is ready for review and the existing author/reviewer commits are retained. Upstream CI needs maintainer approval. I am following the latest-head checks and have not merged or released anything.

@kshivang

Copy link
Copy Markdown
Contributor

@Raghav-Bansal-15 your exact hashed identity paths and metadata validation address the cache collision boundary, and the follow-up keeps lookup/write/purge refusals separate from verified downloads. I independently checked head bed5eeb3f31a3fb41a3bd4de28459d8209797423: the explicit catches cover the cache's syntax/path/I/O refusals, cancellation is rethrown first, root initialization failure is retained without preventing repository construction, and hash/signature verification stays outside the best-effort cache wrapper. The earlier High findings are addressed in this control flow; I am not recycling them.

The remaining merge gate is execution evidence on this head. I approved the fork Build & Test run, which is now queued/running rather than action_required. Your report of 70 local tests and static analysis passing is useful, but I have not independently reproduced that local run. Pending hosted validation is a maintainer/infrastructure condition, not an author defect or a failed test. No checks will be bypassed.

Please retain final-head results for :plugin-platform:plugin-repository:desktopTest, :plugin-platform:plugin-repository:ktlintCheck, and :plugin-platform:plugin-repository:detekt, including cache metadata/link refusals, verified-download fallback, and invalid-signature rejection across the supported platforms. The PR also adds push triggers for fix/security-* and unrelated fix/filesystem-service-*; please drop this review-branch scaffolding or explain why a permanent shared-CI change belongs in this cache fix. Existing legacy-cache/statistics and provider-dependent fileKey limits remain documented follow-ups, not newly claimed blockers.

Under the requested bounded-review policy I am returning this to draft pending final-head validation/review completion, without starting another repair or polling loop. Cache placement in the host's shared plugin repository is correct; #563 is a distinct wizard install-artifact lifecycle fix. No live cache/profile or production data was touched. Original contributor and prior agent repairs remain separately credited; no new source changes in this pass. @claude please review the current explicit-exception revision once, checking cache fallback and signature rejection; the previous completed review predates these repairs.

@kshivang
kshivang marked this pull request as draft September 15, 2026 01:38
@claude

claude Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 4m 28s —— View job


Review: explicit-exception revision (head bed5eeb)

  • Read the diff for the repair commits (b34f6a8 → bed5eeb)
  • Verify cache fallback: which refusals are absorbed, and where
  • Verify signature rejection is outside the best-effort wrapper
  • Check the new caller-level regressions
  • Attempt local execution (not permitted in this environment - see bottom)

Scope as requested: one pass over the current control flow, cache fallback and signature rejection. I am not re-raising the three earlier High findings - they are closed, and I traced each one rather than taking the summary.


What I confirmed

Cache fallback covers exactly the families the cache can raise. cacheOrNull (RemotePluginRepository.kt:54-70) catches IOException (createDirectories/move/copy/channel reads), IllegalStateException (checkDirectory, checkRoot, the entry symlink check), IllegalArgumentException (cacheKey's require) and SecurityException. I walked every throw site in PluginDownloadCache and found none outside that set. The three former Highs are each defused at the caller: a linked entry, an uncacheable version string and a failed promotion all become a warn and a fresh download.

One thing there is load-bearing and looks cosmetic. Cancellation is rethrown first (:60-61), and it has to be: kotlinx.coroutines.CancellationException is java.util.concurrent.CancellationException, which extends IllegalStateException. Move that clause below :64 and a cancelled install silently becomes "cache unavailable" plus a completed download. Worth one comment line, because the next person tidying catch order has no way to know.

Verification stays outside the wrapper. enforceStoreSignature is called at :377 (cache hit) and :459 (fresh) with nothing wrapped around it, and DownloadException extends Exception (PluginRepository.kt:128), so it cannot be swallowed by any of the four catches even indirectly. At :384 the wrapper encloses only removeCachedJar, so a purge that itself refuses cannot mask the rejection - the throw still happens. cacheJar is reached only after the SHA check, enforceStoreSignature and PluginSignatureSidecar.persist, so :474 can no longer fail a trusted artifact.

Rejection coverage is intact: invalid signature throws and deletes all files, garbage signature throws, substituted artifact…fails, store-reported version differing…fails, plus cache hit still verifies signature and purges poisoned entry and fresh download with invalid signature fails and is never cached. The four new caller-level tests are each genuine mutation tests - unwrap any one cacheOrNull and the matching test fails.

Root init is retained, not thrown (PluginDownloadCache.kt:28-34), construction cannot fail, and unavailable cache does not prevent repository construction or verified download pins it. The Windows fileKey()-is-null limit is now documented at :178.


Medium

1. The tamper warning now fires on every ordinary cache miss - PluginDownloadCache.kt:49-52

if (!Files.isRegularFile(path, NOFOLLOW_LINKS) || readMetadata(path) == null) {
    logger.warn(LogCategory.SYSTEM, "Ignoring plugin cache entry with invalid identity metadata")
    return null
}

A cold cache, a first-ever install of a plugin, and a hand-planted sidecar all emit the same WARN, and the text names an anomaly in the case where there is simply no file. This is the warning the previous review asked to restore as the tamper signal, and routine emission is what removes its value: an operator on a fresh profile sees it for every plugin they install. The condition also conflates two different facts. Splitting is cheap - return null silently when !Files.isRegularFile(...), and warn only when the jar exists and its metadata is absent or does not match.

Fix this →

2. cacheUnavailable drops the exception message - RemotePluginRepository.kt:76-79

It logs operation and failureType only. The refusals it now absorbs are precisely the security-relevant ones, and they are indistinguishable in the log from a full disk:

Underlying Message Reported as
root replaced under us Plugin cache directory was replaced IllegalStateException
planted link at the entry Plugin cache entry must not be a symbolic link IllegalStateException
linked plugin directory Plugin cache directory must not be a symbolic link IllegalStateException
uncacheable id/version Invalid plugin cache ID or version IllegalArgumentException
disk full / read-only NIO text IOException

All four non-IO messages are constant strings with no user data, so including failure.message for the check/require families costs nothing and is the difference between "the cache was tampered with" and "the disk is full". For IOException the NIO message embeds absolute paths (so, usernames), which is why I would not log it raw there - omit it or route it through the sanitizer, per AGENTS.md.

Related: the IllegalArgumentException case is permanent, not "unavailable". A store row with 1.0.0 (beta) will re-log on every install of that version for ever, while the word "unavailable" implies a fault that clears.


Low / follow-ups

  • A refused or metadata-mismatched entry never self-heals. getCachedJar deletes only on SHA mismatch (:69-70); a linked entry, a mismatched sidecar and a missing sidecar all leave the file in place, and nothing in production reclaims it - PluginStoreSetup.clearCache (:434) and getCacheStats (:441) have no callers, cleanOldEntries has none at all. The consequence is now perf-only (that plugin/version misses for ever) rather than the install block, so this is correctly a follow-up - but it does mean the poison this PR detects stays on disk indefinitely, which is worth saying in the follow-up note alongside the legacy sweep.
  • PluginStoreSetup.clearCache/getCacheStats call the cache unwrapped. Every remaining public cache method can now throw IllegalStateException from checkRoot or rethrow the retained init failure. Latent only because nothing calls them; a future "Clear cache" button needs the same treatment downloadPlugin got.
  • Residual TOCTOU on the hit path, unchanged. getCachedJar hashes the file and returns a path; the caller copies it at :387. Under the same local-write threat model the symlink checks address, the bytes can change in between. The NOFOLLOW read and ATOMIC_MOVE publication narrow it and load-time sidecar verification is what actually covers it - just worth not claiming more than that.
  • cacheKey compiles a fresh Regex per call (:212); hoist to a private val.
  • java.io.IOException is fully qualified inline at :62; import it like the rest.
  • cleanOldEntries counts sidecars as removed entries, so its return is roughly double the version count, and it no longer prunes emptied p-<hash> directories.
  • paths.filter { it != directory || includeRoot } (:232) is a constant predicate when includeRoot is true; if (includeRoot) paths else paths.filter { it != directory } reads plainly.
  • @Synchronized is per instance and not cross-process (the atomic moves carry that weight). One KDoc line would stop a second instance being assumed serialized, since PluginStoreSetup:268 is the only thing keeping it single today.
  • The Windows-junction gap from the earlier review is closed in effect, though untested: a junction at the p-<hash> directory fails checkDirectory's toRealPath() comparison, and a junction cannot exist at the jar path (junctions are directories), where a directory fails the move with an IOException that is now absorbed.

Coverage

rejectsPathSyntaxWithoutTouchingOutsideFiles now pins the syntax require at the cache boundary only, which is the right place given the caller absorbs it. unsupported cache version syntax still downloads and verifies covers the download; nothing asserts the second install of such a version also downloads (i.e. that no entry was written). Still open from the earlier list, unchanged and reasonable to defer: a legacy-layout entry test, a checkRoot replaced-directory test, and no Assumptions guard on the symlink tests. On that last one - declining the skip absent a real privilege failure is defensible, but note this revision takes the symlink-creating surface on the windows-latest build leg from two tests to three.

The workflow change

I agree with dropping it. fix/filesystem-service-* is unrelated to a plugin-cache fix, and a push trigger on a branch that also has an open PR doubles the runs for everyone on those prefixes. It is a permanent shared-CI change arriving inside a targeted fix, which is the kind of thing that outlives the review it was added for. I cannot edit .github/workflows myself (GitHub App permissions do not allow workflow modifications), so that one is for @Raghav-Bansal-15.

Verdict

The cache-fallback control flow and the signature-rejection boundary are both correct on this head, and the reviewer's placement decisions (keep the refusals in the cache, absorb them at the caller, keep hash/signature outside the wrapper) hold up against the code. Nothing here is a blocker: M1 is the one I would fix before merge, because it undoes the observability the earlier review asked for, and M2 is a two-line change in the same area.

I could not run Gradle in this environment (./gradlew is not a permitted command here), so I am claiming no desktopTest/ktlintCheck/detekt result of my own - the hosted final-head run remains the execution evidence.
· branch fix/security-plugin-cache

kshivang and others added 18 commits September 14, 2026 20:46
…-labs-inc#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\<name>\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 03ba543.

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.
…bs-inc#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 risa-labs-inc#667

* test: cover missing JxBrowser compile modules in release gate

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
…risa-labs-inc#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 risa-labs-inc#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 risa-labs-inc#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 risa-labs-inc#418

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
…reload (risa-labs-inc#468)

* 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 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <johnk@johns-MacBook-Pro.local>
Co-authored-by: john k <johnk@syn-172-100-137-112.res.spectrum.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>

* 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 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <adityakolate0070@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* #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 <shivang.iitk@gmail.com>

* 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 <aishwary.cd22@bmsce.ac.in>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <sanjaysaini4423@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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) <noreply@anthropic.com>

* 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 <path>". 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* :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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* :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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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) <noreply@anthropic.com>

* 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 <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <singlaarjun28@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <antrikshsingh850@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* 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 <shivang.iitk@gmail.com>

* # 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 <noreply@anthropic.com>

* fix(mcp): repair governed dispatch and cancellation audit guarantees

* fix(mcp): address governed autonomy review defects

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* #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.
- `Key…
…-labs-inc#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 <History> 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 risa-labs-inc#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
   "<KeePassFile" was misrouted to the XML parser and rejected. Routing now anchors
   on the document root element (past an optional BOM, XML declaration and
   comments), so a marker inside CSV data no longer misroutes.

Regressions added for each: verbatim/all-whitespace passwords, a no-declaration
KeePass root, and a CSV/reader that carries the marker in a field.

* fix(import): preserve exclusions and validate manager previews

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
* fix(filesystem): report failed file mutations

* fix(filesystem): harden non-recursive deletion

* test(filesystem): harden mutation regression coverage

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
…a-labs-inc#696) (risa-labs-inc#697)

* fix(updater): stop the update prompt stacking over another modal (risa-labs-inc#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 risa-labs-inc#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 <shivang.iitk@gmail.com>
@Raghav-Bansal-15
Raghav-Bansal-15 marked this pull request as ready for review September 15, 2026 06:26
@kshivang

Copy link
Copy Markdown
Contributor

@claude please review final cache head 5866e9a once. Current dev is integrated; only four cache production/test files differ from dev. Prior High findings remain repaired. The author fixed ordinary-miss warning noise and documented cancellation catch ordering, and removed unrelated workflow triggers. Raw exception messages remain intentionally omitted because cache IO/security exceptions may carry sensitive paths; operation and exception type remain available. Please check remaining correctness and regression concerns without repeating resolved findings.

@claude

claude Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 4m 25s —— View job


Review: final cache head 5866e9a

  • Read the diff against origin/dev (4 files, no .github/ delta - the branch triggers are gone)
  • Verify the two prior Mediums as fixed
  • Audit remaining correctness on this head
  • Assess regression coverage

Scope as asked: one pass, no re-raising of the three earlier Highs or of M1/M2. I traced each rather than taking the summary.


Confirmed on this head

  • M1 (miss noise) is fixed correctly, and the guard is tighter than it looks: Files.exists(path, NOFOLLOW_LINKS) (PluginDownloadCache.kt:49-52) can only be true here for a present non-regular entry or a present jar with absent/mismatched metadata, because a symlink at that path has already thrown out of cacheFile. A cold cache and a first-ever install are silent.
  • Cancellation ordering is documented at the catch (RemotePluginRepository.kt:58-60). That comment is load-bearing - kotlinx.coroutines.CancellationException is java.util.concurrent.CancellationException, which extends IllegalStateException, so moving that clause below the IllegalStateException catch silently turns a cancelled install into "cache unavailable" plus a completed download.
  • The catch set matches the throw sites of the three wrapped operations. I walked getCachedJar / cacheJar / removeCachedJar and every reachable throw is IOException, IllegalStateException (the two checks + checkRoot), IllegalArgumentException (cacheKey's require, and InvalidPathException from the retained init failure, which is an IllegalArgumentException) or SecurityException.
  • Verification stays outside the wrapper. enforceStoreSignature is unwrapped at :376 and :459; only onVerificationFailure's purge is wrapped, so a refusing purge cannot mask a rejection. cacheJar is reached only after the SHA check, enforceStoreSignature and PluginSignatureSidecar.persist.
  • Omitting exception messages is a reasonable call given IOException text embeds absolute paths; I'm not re-litigating it. One narrow consequence is in Low below.

Medium

1. The cache-hit path's copy is outside the fallback, so a cache entry that disappears between lookup and copy still fails the install

val cachedFile = cacheOrNull("lookup") { downloadCache.getCachedJar(...) }   // :360
if (cachedFile != null) {
    enforceStoreSignature(...)                                               // :376
    cachedFile.copyTo(File(targetPath), overwrite = true)                    // :387  <- unwrapped
    PluginSignatureSidecar.persist(targetPath, downloadInfo.signature)
    return@runCatching targetPath
}

copyTo throws kotlin.io.NoSuchFileException (an IOException) if the entry is gone, and it lands in downloadPlugin's outer runCatching (:350) as Result.failure - the fresh download below is never attempted. The @Synchronized lock does not cover this: it is released when getCachedJar returns, and the entry can be removed in between by removeAllVersions, clearCache, cleanOldEntries, or a second process sharing ~/.boss/plugin-cache.

Race-only, so not a blocker - but it is the one place where the stated invariant at :53 ("Cache availability must never decide whether a verified download succeeds") does not hold, and the residual TOCTOU window on this path is now the only reason the cache can still fail an install. Letting an IOException from the copy log and fall through to the fresh-download path closes it, and costs nothing on the happy path.

Fix this →

2. cacheOrNull does not catch UncheckedIOException, and every Files.walk-based method can raise it

Files.walk(...).toList() throws UncheckedIOException (a RuntimeException) when directory iteration fails mid-walk - unreadable subdirectory, a directory removed under the walk. That covers entries() and deleteEntries(), hence clearCache, removeAllVersions, cleanOldEntries, getCacheSize, getCachedFileCount and listCachedPlugins.

None of those is on the downloadPlugin path today, so this is latent rather than live. What makes it worth recording: PluginStoreSetup.clearCache (:441) and getCacheStats (:448) call straight into them with no wrapper, and on this head every public cache method can additionally throw IllegalStateException from checkRoot() or rethrow the retained init failure from location.getOrThrow(). They have no production callers, so nothing breaks now - but the first "Clear cache" / cache-stats UI wired to them inherits exactly the failure mode downloadPlugin was just repaired for. Either widen cacheOrNull (UncheckedIOException, DirectoryIteratorException) and reuse it there, or note on those two methods that a caller must handle refusals.


Low

  • The metadata-first publication rationale doesn't hold, and its one real effect is an orphan sidecar. cacheJar:88-91 publishes the sidecar before the jar "so a failed metadata write cannot replace an existing artifact" - but CacheMetadata is a pure function of the key (the same {pluginId, version} on every write to that path), so either order leaves a consistent pair. The asymmetry it does introduce: when the jar move fails after the sidecar move succeeded, a v-<hash>.json with no jar is left behind, counted by getCacheSize and reclaimable only by expiry. Jar-first would leave nothing.
  • A refused entry never self-heals, and the promotion-failure state re-warns forever. getCachedJar deletes only on SHA mismatch (:70-72); a linked entry, a mismatched sidecar, a missing sidecar and a directory at the jar path all stay. The last one is concrete - cache promotion failure preserves a successful verified download leaves exactly that state, and from then on every install of that plugin/version logs "Ignoring plugin cache entry with invalid identity metadata" and re-downloads. Consequence is perf plus log noise, not an install block, so a follow-up is right; worth adding to the legacy-sweep note that the poison this PR detects also stays on disk.
  • "Identity metadata" bounds layout, not tampering. The sidecar's content is derivable from the key, so anyone who can plant a jar in the cache can plant its sidecar; what the check genuinely rules out is a stale, legacy-layout or colliding entry being served under a key it was not written for. The cacheJar comment already says "it does not vouch for bytes" - the PR description's "require matching metadata" reads stronger than that, and the actual byte binding is the store SHA-256 plus enforceStoreSignature. Worth one sentence in the description so the guarantee isn't over-read later.
  • "unavailable" is the wrong word for the IllegalArgumentException case. A store row with 1.0.0 (beta) is permanently uncacheable, so that warning repeats on every install of that version while the wording implies a transient fault. A static reason discriminator ("reason" to "unsupported-key" / "refused-path" / "io"), chosen at the catch rather than taken from the message, separates "the disk is full" from "the cache refused this entry" without printing any path - it doesn't reopen the omitted-message decision.
  • readMetadata's runCatching { ... }.getOrNull() (:150-162) also swallows checkRoot()'s "Plugin cache directory was replaced" (via the nested cacheFile) and, being runCatching, any Error. It fails safe as a miss, but that one condition is a refusal everywhere else in the class.
  • Earlier nits unchanged and still fine to defer: per-call Regex in cacheKey (:212), inline java.io.IOException at RemotePluginRepository.kt:62, paths.filter { it != directory || includeRoot } as a constant predicate, cleanOldEntries counting sidecars (so roughly double the version count) and no longer pruning emptied p-<hash> directories.

Regression coverage

The four new caller-level tests are genuine mutation tests - unwrapping any single cacheOrNull fails the matching one - and lookupRequiresMatchingRegularIdentityMetadata covers all five metadata states (wrong id, wrong version, malformed, absent, linked) plus the sentinel. refusesSymlinkedEntryAndPluginDirectory and cleanupDoesNotTraverseLinkedDirectories still fail if either check is dropped.

Remaining gaps, in order of how much I'd want them:

  1. Nothing asserts a second install of an unsupported-syntax version also downloads. unsupported cache version syntax still downloads and verifies proves the first one falls back; the claim that matters operationally is that no entry was written and the next install behaves identically. One extra downloadPlugin call plus assertEquals(0, cache.getCachedFileCount()) pins it.
  2. preservesIdsVersionsAndCaseWithoutCacheCollisions couples two assertions invisibly. assertEquals(3, cache.clearCache()) is only 3 (not 4) because the preceding assertNull(getCachedJar(..., "invalid")) deletes the mismatched entry as a side effect. Correct, and a good implicit check of that branch - but a reader changing either line gets a confusing failure. One comment, or split the mismatch case out.
  3. Still open and reasonable to defer: a legacy-layout entry test (<id>/<id>_<ver>.jar → miss, and reclaimed by clearCache), and a checkRoot replaced-directory test (POSIX-only, as :178 now documents).
  4. Four symlink-creating tests now run on the windows-latest build leg (three here plus linked cache entry does not block a verified download). Declining an Assumptions guard absent a real privilege failure is defensible - just noting the surface grew, and that the hosted final-head run is the first thing that would tell you.

Verdict

No blockers. The cache-fallback control flow and the signature boundary are correct on this head, the two repaired Mediums are genuinely repaired, and the workflow scaffolding is gone. Medium 1 is the only remaining place the "cache never fails a verified download" invariant leaks, and it is a narrow race; Medium 2 is latent until someone wires a cache UI. Everything else is follow-up or wording.

I could not run Gradle here (./gradlew is not permitted in this environment), so I'm claiming no desktopTest / ktlintCheck / detekt result of my own - the hosted final-head run remains the execution evidence.
· branch fix/security-plugin-cache

@kshivang

Copy link
Copy Markdown
Contributor

@Raghav-Bansal-15 your exact-identity cache hardening and follow-up fixes are preserved. I integrated current rebased dev, resolved the documentation conflict using current dev, and pushed 5866e9a. The resulting diff contains only four cache production/test files.

Independent final-head validation passed under the shared build lock: 70 repository tests (zero failures/errors/skips), repository ktlintCheck and detekt. No new source repair was needed. Prior metadata/fallback agent repairs remain separately credited in the description.

I approved upstream run https://github.com/risa-labs-inc/BossConsole/actions/runs/34959436380 and requested one current Claude review. Both are still running at this checkpoint; the PR stays ready, not draft. Pending CI is not an author defect, and the earlier successful fork run is not a substitute for final-head required checks. Merge into dev awaits those gates. No merge into main is authorized.

@Raghav-Bansal-15

Copy link
Copy Markdown
Contributor Author

Follow-up at d3731ec9: The cache-copy race is fixed: a disappeared cached JAR falls back to a fresh verified download, with signature enforcement still outside the best-effort cache wrapper. The regression fails with the unprotected copy. All 71 repository tests and quality checks pass locally.

Original and reviewer commits are preserved. Remaining current-head hosted checks and upstream approval are separate gates; no merge or release.

@kshivang

Copy link
Copy Markdown
Contributor

Reviewed author follow-up d3731ec against the completed Claude review: the cache-copy refusal now falls through to fresh download, while signature verification and sidecar publication remain outside the fallback wrapper. The regression deletes the actual cached file at the copy boundary and asserts downloaded bytes plus signature. This addresses Medium 1. Medium 2 concerns currently uncalled cache maintenance/statistics APIs and remains a documented follow-up; no broad exception rewrite is needed on the install path. Prior rating and original/reviewer attribution remain unchanged. Exact-head fork CI is green; upstream run 34978513594 is approved and executing. Current dev integration is conflict-free and focused validation is running.

@kshivang
kshivang merged commit c1f03ea into risa-labs-inc:dev Sep 15, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants