Skip to content

test: run the nine @Test methods JUnit was skipping, and fail on any it skips - #1667

Merged
fluck-boss merged 1 commit into
risa-labs-inc:devfrom
arjun28115:fix/tests-that-never-run
Sep 24, 2026
Merged

fluck-boss merged 1 commit into
risa-labs-inc:devfrom
arjun28115:fix/tests-that-never-run

Conversation

@arjun28115

Copy link
Copy Markdown
Contributor

Nine @Test methods in composeApp have never run. One of them no longer describes the code. The build stays green through all of it, and this PR makes that impossible to repeat.

Why they don't run

@Test
fun `requestApproval times out and fails closed if operator does not respond`() =
    runBlocking {
        ...
        assertIs<McpApprovalDecision.Timeout>(decision)   // returns the Timeout
    }

An expression body takes the type of its last expression, and runBlocking<T> returns whatever its block does. So a test that ends in assertIs, assertNotNull, assertFailsWith or a call returning a value compiles to a method that returns a value. JUnit Jupiter does not execute a @Test method that returns a value. It reports a WARNING-level discovery issue, which Gradle doesn't show, and it doesn't count the method as skipped. Nothing fails. The method just isn't there.

The nine, found by reading the return type of every @Test method in the compiled test classes, then confirmed missing from a run's results:

Test class Method Returned
McpApprovalGateTest requestApproval times out and fails closed if operator does not respond McpApprovalDecision.Timeout
McpApprovalGateTest exceeding pending buffer capacity immediately returns Denied buffer full McpApprovalDecision
McpApprovalGateTest deny all rejects exactly the visible snapshot without persisting policy McpApprovalDecision.Approved
McpApprovalGateTest an approval request carries the tool description, policy, and remaining timeout to the dialog McpApprovalDecision
PluginUIServiceBridgeTest a plugin that dies right after binding leaves no stuck claim behind SurfaceRegistration.Accepted
PluginUIServiceBridgeTest unregistering an unknown surface is unaffected by having no credential (renamed, see below) Empty
UpdateJarIdentityVetTest a jar that is not there is rejected Throwable
RepairAdviceTest caller cancellation is not converted to missing advice CancellationException
WorkspaceMcpToolProviderTest open_workspace with createIfAbsent says the file was saved when the window never registers LayoutWorkspace

Four are the MCP approval gate's own guarantees: fail closed on timeout, and deny when the buffer is full.

What running them found

Eight pass once they run. One doesn't:

unregistering an unknown surface is unaffected by having no credential asserts that a caller with no credential can unregister a surface nobody owns. That was true when it was written (#430, Sep 9). Since #776 (Sep 16), ProcessIdentityInterceptor authenticates every RPC, so the call is refused UNAUTHENTICATED before the bridge sees it. The tightening was deliberate and the interceptor's KDoc says so. The test just never ran to notice. It is renamed an unknown surface unregisters as a no-op, but only for an authenticated caller and pins the current contract: an authenticated caller still gets the bridge's idempotent no-op, and an anonymous one is refused UNAUTHENTICATED at the transport.

The change

  • Each of the nine is declared (): Unit =. With an expected type of Unit, the block's last expression is coerced and the method returns void. Nothing else in eight of them changes.

  • The stale test is rewritten as above, with a KDoc saying why.

  • composeApp's Test tasks set junit.platform.discovery.issue.severity.critical=WARNING, so a discovery issue fails the run instead of passing silently. Checked: with one of the nine reverted to () =, the run fails with

    DiscoveryIssueException: TestEngine with ID 'junit-jupiter' encountered a critical issue during test discovery:
    (1) [WARNING] @Test method '... RepairAdviceTest.caller cancellation is not converted to missing advice()' must not return a value. It will not be executed.
    

Verification

  • Full :composeApp:desktopTest with the guard on: 6,529 tests, 0 failed, 5 skipped. The nine all run and pass. The guard finds no other discovery issue anywhere in composeApp.

  • ktlintCheck and detekt: clean.

  • What running them buys, measured by breaking the approval gate (McpApprovalGateTest, as on dev and with this PR):

    Mutation in McpApprovalBus As on dev (3 tests run) With this PR (7 run)
    a full buffer returns Approved() instead of QueueFull, so the tool runs without approval not caught, build green caught by exceeding pending buffer capacity immediately returns Denied buffer full
    a timeout returns Approved() instead of Timeout caught by a neighbouring test caught by that test and requestApproval times out and fails closed

    In fairness to the existing suite: the second row was already covered, and the cap has two layers (the pending-list check and the request channel's capacity), so an off-by-one in either alone is masked by the other. The first row is the one that shipped unguarded.

  • The other modules: plugin-platform's compiled test classes (icons, repository, logging, loader) have no value-returning @Test. The guard is set in composeApp's tasks.withType<Test> only, which is where all nine were. Widening it to every module is a one-line follow-up if wanted.

🤖 Generated with Claude Code

…it skips

An expression-bodied test takes the type of its last expression, and runBlocking<T>
returns whatever its block does. So a test ending in assertIs/assertNotNull/assertFailsWith
compiles to a method returning a value, and JUnit Jupiter does not execute a @test method
that returns a value. It reports a WARNING-level discovery issue, which Gradle does not show,
and the build stays green.

Nine composeApp tests were in that state, four of them the MCP approval gate's fail-closed
guarantees. Each is now declared `(): Unit =`.

Eight pass once they run. The ninth asserted that a caller with no credential can unregister
an unknown UI surface. That stopped being true when risa-labs-inc#776's ProcessIdentityInterceptor began
authenticating every RPC, and the test never ran to notice. It now pins the current contract:
a no-op for an authenticated caller, UNAUTHENTICATED without a credential.

composeApp's Test tasks set junit.platform.discovery.issue.severity.critical=WARNING, so a
test JUnit will not run fails the build. Reverting one of the nine fails it with
"@test method ... must not return a value. It will not be executed."

Full desktopTest: 6,529 tests, 0 failed. On dev, an approval bus whose full buffer returns
Approved() passes the build; with this change the buffer test catches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fluck-boss

Copy link
Copy Markdown
Collaborator

@claude review

@claude

claude Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Claude finished @fluck-boss's task in 6m 8s —— View job


Review: PR #1667

  • Read the diff against origin/dev
  • Verify the nine signature changes
  • Check the Gradle discovery-issue guard
  • Check the rewritten PluginUIServiceBridgeTest case
  • Look for other value-returning @Test methods the guard would catch
  • Post review

Verdict: correct, well-diagnosed, and worth landing. The mechanism is described accurately, the (): Unit = fix is the right one (the expected type propagates into runBlocking<T> / withTempDir<T>, T infers Unit, and the lambda's last expression is coerced), the one stale test is genuinely stale, and the guard is the part that stops this recurring. Nothing here is a blocker. Below are three things worth considering and two nits.

What I verified

  • All nine compile-shape changes are sound. Eight wrap runBlocking; UpdateJarIdentityVetTest:115 wraps withTempDir, whose signature is internal inline fun <T> withTempDir(block: (File) -> T): T (PluginJarTestFixtures.kt:53) — generic, so T = Unit infers. Longest new line is ~113 chars against max_line_length = 140.
  • The rewritten test matches the file's own idiom. PluginUIServiceBridgeTest:622-628 is the same assertFailsWith<StatusException> / Status.Code.UNAUTHENTICATED shape as the neighbouring RegisterUI with no credential at all is refused (:541-545), and pluginAs/anonymousPlugin (:131, :144) do what the new assertions need.
  • No global-state leak from the newly-running tests. WorkspaceMcpToolProviderTest sets WorkspaceMcpToolProvider.windowCreator (a process-global) at :316, and @AfterTest at :78-81 nulls it — so the test that had never run before does not now leak into its neighbours.
  • I looked for other offenders outside composeApp (the guard's blind spot). Grepping every module for an expression-bodied @Test ending in assertIs / assertNotNull / assertFailsWith, the hits in modules/* and plugin-platform/* are all either block-bodied, runTest (JVM TestResult is Unit, which the 6,529-test run implicitly confirms), or — in plugin-loader — the runBlocking<Unit> idiom (LoadTimeSignatureVerificationTest:97, 110, 122, …, MinApiVersionGateTest). So nothing is currently broken there, but note that this trap has already been met and solved a second way in this repo, which is an argument for the guard rather than against it.

1. The guard's scope is narrower than the failure mode (worth widening now)

The property is set in composeApp/build.gradle.kts:2500 only. My grep above is a point-in-time check, and it cannot see the shape that actually bit you hardest — a last expression that is an ordinary value-returning call (SurfaceRegistration.Accepted, LayoutWorkspace), which no regex finds. modules/*, server/ and buildSrc are unguarded.

There is a root allprojects { } at build.gradle.kts:42 that already carries the cross-module conventions (detekt, ktlint). One line there covers everything:

tasks.withType<Test>().configureEach {
    systemProperty("junit.platform.discovery.issue.severity.critical", "WARNING")
}

The PR body offers this as a follow-up; given the property is inert where there is nothing to report, I'd take it in this PR rather than leave the guarantee module-shaped. Fix this →

2. A Gradle system property does not reach the IDE

systemProperty on the Test task only applies when tests run through Gradle. A developer running a test class from IntelliJ with Run tests using: IntelliJ IDEA gets no guard, so the feedback arrives at CI rather than at the keystroke that caused it — for a defect whose whole character is "you don't notice".

A junit-platform.properties on the test runtime classpath (composeApp/src/desktopTest/resources/junit-platform.properties) is read by the platform however it is launched, and is also per-module without editing a build file each time. There is none in the repo today, so no conflict. Either mechanism alone works; the resource is strictly the wider net, and a system property still overrides it if someone needs to relax it for one run.

3. Please say in the comment what a failure looks like

This is the part I'd most like added, because the blast radius is not obvious: a DiscoveryIssueException aborts discovery for the whole engine. One warning anywhere in composeApp means zero tests run, not one test failing. And useJUnitPlatform() here registers vintage as well (junit-vintage-engine, for the JUnit 4 rules createComposeRule needs), so a future Compose or JUnit bump that introduces a new WARNING-level discovery issue takes the entire suite out at once, with an error message that does not look like a test failure.

That is the right trade — silently skipping is worse — but the next person to meet it will be staring at 6,500 vanished tests. One more sentence in the comment at composeApp/build.gradle.kts:2496-2500 naming the property and saying "relax this to ERROR if a toolchain bump introduces an unrelated warning" turns a confusing outage into a two-minute fix.

Relatedly, this repo records invariants of exactly this kind in AGENTS.md (there is already a composeApp test-isolation subsection under Workflow Rules). "A @Test that returns a value is not executed; declare it (): Unit =" belongs there too — a build-file comment is not where someone writing a test looks.

Nits

  • McpApprovalGateTest:145 uses delay(50) followed by assertEquals(2, bus.pendingList.value.size), where its sibling at :166 uses the deterministic bus.pendingList.first { it.size == 2 }. In practice runBlocking's single-threaded event loop makes the delay near-deterministic, so this is not a live flake — but this test has never run on CI before, and a fixed sleep as a scheduling barrier is the kind of thing that only starts failing under load. Since the file already has the right form ten lines away, I'd switch it while you're in here.
  • PluginUIServiceBridgeTest:625 — the "no-op" half asserts only that the call did not throw. Adding an assertNull(registry.surfaceOf("never-registered")) after it (or asserting a real registration is untouched) would make no-op an observed fact rather than the absence of an exception, which is the standard the KDoc you wrote sets for the other half.

None of these change my read: this is a good catch, the diagnosis is right, and the guard is the durable half.
• branch fix/tests-that-never-run

@fluck-boss

Copy link
Copy Markdown
Collaborator

Merging. Gate complete: CI green on the head, Claude review has nothing blocking, my own read clean.

@fluck-boss
fluck-boss merged commit f1f72b7 into risa-labs-inc:dev Sep 24, 2026
11 checks passed
kshivang pushed a commit that referenced this pull request Sep 24, 2026
…it skips (#1667)

An expression-bodied test takes the type of its last expression, and runBlocking<T>
returns whatever its block does. So a test ending in assertIs/assertNotNull/assertFailsWith
compiles to a method returning a value, and JUnit Jupiter does not execute a @test method
that returns a value. It reports a WARNING-level discovery issue, which Gradle does not show,
and the build stays green.

Nine composeApp tests were in that state, four of them the MCP approval gate's fail-closed
guarantees. Each is now declared `(): Unit =`.

Eight pass once they run. The ninth asserted that a caller with no credential can unregister
an unknown UI surface. That stopped being true when #776's ProcessIdentityInterceptor began
authenticating every RPC, and the test never ran to notice. It now pins the current contract:
a no-op for an authenticated caller, UNAUTHENTICATED without a credential.

composeApp's Test tasks set junit.platform.discovery.issue.severity.critical=WARNING, so a
test JUnit will not run fails the build. Reverting one of the nine fails it with
"@test method ... must not return a value. It will not be executed."

Full desktopTest: 6,529 tests, 0 failed. On dev, an approval bus whose full buffer returns
Approved() passes the build; with this change the buffer test catches it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
fluck-boss pushed a commit that referenced this pull request Sep 25, 2026
…composeApp's Gradle run (#1699)

Review follow-up on #1667, which merged before these landed.

- The discovery-issue guard moves from composeApp's Test tasks to the root allprojects
  block, so every module's Gradle test run fails on a @test JUnit will not execute.
  Checked by adding a value-returning test to plugin-loader, whose run then failed with
  "must not return a value".
- composeApp repeats it in src/desktopTest/resources/junit-platform.properties, which
  JUnit reads however it is launched, so an IDE run meets it too. Checked with the root
  property removed: the same probe in composeApp still failed the run.
- The build comment says what a failure looks like (discovery aborts for the whole engine,
  and one initializationError names the method) and to relax the property to ERROR rather
  than delete it if a toolchain bump brings an unrelated warning.
- AGENTS.md records the rule beside the composeApp test-home note: declare an
  expression-bodied test `(): Unit =`, or write runBlocking<Unit>.
- McpApprovalGateTest's buffer test waits on pendingList instead of a 50 ms sleep, as its
  deny-all sibling already does. The unknown-surface test now observes the no-op: nothing
  appears, and a real surface survives.

All 29 modules' test tasks with the guard on: 8,414 tests, 0 failed. ktlint and detekt
clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
fluck-boss pushed a commit that referenced this pull request Sep 25, 2026
…composeApp's Gradle run (#1699)

Review follow-up on #1667, which merged before these landed.

- The discovery-issue guard moves from composeApp's Test tasks to the root allprojects
  block, so every module's Gradle test run fails on a @test JUnit will not execute.
  Checked by adding a value-returning test to plugin-loader, whose run then failed with
  "must not return a value".
- composeApp repeats it in src/desktopTest/resources/junit-platform.properties, which
  JUnit reads however it is launched, so an IDE run meets it too. Checked with the root
  property removed: the same probe in composeApp still failed the run.
- The build comment says what a failure looks like (discovery aborts for the whole engine,
  and one initializationError names the method) and to relax the property to ERROR rather
  than delete it if a toolchain bump brings an unrelated warning.
- AGENTS.md records the rule beside the composeApp test-home note: declare an
  expression-bodied test `(): Unit =`, or write runBlocking<Unit>.
- McpApprovalGateTest's buffer test waits on pendingList instead of a 50 ms sleep, as its
  deny-all sibling already does. The unknown-surface test now observes the no-op: nothing
  appears, and a real surface survives.

All 29 modules' test tasks with the guard on: 8,414 tests, 0 failed. ktlint and detekt
clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
kshivang pushed a commit that referenced this pull request Sep 28, 2026
…composeApp's Gradle run (#1699)

Review follow-up on #1667, which merged before these landed.

- The discovery-issue guard moves from composeApp's Test tasks to the root allprojects
  block, so every module's Gradle test run fails on a @test JUnit will not execute.
  Checked by adding a value-returning test to plugin-loader, whose run then failed with
  "must not return a value".
- composeApp repeats it in src/desktopTest/resources/junit-platform.properties, which
  JUnit reads however it is launched, so an IDE run meets it too. Checked with the root
  property removed: the same probe in composeApp still failed the run.
- The build comment says what a failure looks like (discovery aborts for the whole engine,
  and one initializationError names the method) and to relax the property to ERROR rather
  than delete it if a toolchain bump brings an unrelated warning.
- AGENTS.md records the rule beside the composeApp test-home note: declare an
  expression-bodied test `(): Unit =`, or write runBlocking<Unit>.
- McpApprovalGateTest's buffer test waits on pendingList instead of a 50 ms sleep, as its
  deny-all sibling already does. The unknown-surface test now observes the no-op: nothing
  appears, and a real surface survives.

All 29 modules' test tasks with the guard on: 8,414 tests, 0 failed. ktlint and detekt
clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
kshivang added a commit that referenced this pull request Sep 29, 2026
* fix(settings): recover corrupt keymap, zoom and workspace settings files, and lock zoom updates (#938)

Co-authored-by: abhinavlenka <abhinavlenka@users.noreply.github.com>

* fix(plugins): follow a Downloads folder the user moved on Windows

Windows lets the user move Downloads (Explorer, Properties, Location), for
instance to D:\Downloads, but BOSS kept answering %USERPROFILE%\Downloads.

- WindowsDownloadsFolder reads the shell's value for FOLDERID_Downloads with
  reg.exe, parses REG_SZ and REG_EXPAND_SZ, expands %NAME% variables and keeps
  only an absolute path. It needs no native binding, so the host and plugin
  processes read it the same way. Read once per JVM; a missing value, a
  missing reg.exe or a run past 5 s fall back to the old answer. It is only
  consulted when user.home is the Windows profile, so test tasks that
  redirect user.home never read the registry.
- DownloadsDirectory.current() passes it as the known folder, which still
  wins only when it names a real directory.
- FileSystemDataProviderImpl.writeFile/readFile admit the Downloads folder the
  provider hands out, and paths under it, in addition to home. Compared on
  real paths, so .., symlinks and junctions cannot lead into a sibling; a
  Downloads folder at a filesystem root admits nothing; delete stays
  home-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): log an unread Downloads known folder once, and make two reader tests test what they say

Review follow-up for this PR.

- The desktop getDefaultDownloadsDirectory logs once per JVM when the Windows
  known folder could not be read, so a failed registry read is no longer
  indistinguishable from a folder that was never moved. plugin-path-utils stays
  logger-free.
- The commonMain expect KDoc no longer says Windows is always
  %USERPROFILE%\Downloads.
- WindowsDownloadsFolder.current checks os.name itself instead of relying on
  its only caller to.
- "a successful run returns its standard output" ran java -version, which
  prints to stderr, so it passed on empty output; it now runs --version and
  checks the version is in what came back (a planted stdout-dropping mutation
  fails it). The timeout test uses a program that sleeps 60 s against a 1 s
  timeout instead of a 0 ms race.
- RelocatedDownloadsAccessTest's outside-home premise is a @BeforeTest
  assumption, so it gates every case instead of only itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(build): ship plugin-path-utils with the upstream jars plugin-api-ipc needs

plugin-api-ipc calls DownloadsDirectory from plugin-path-utils at runtime
(FileSystemDataProviderProxy), but assembleUpstreamJars shipped only four jars,
none of which contains it. The standalone microkernel runtime would compile
against plugin-api-ipc and then fail with NoClassDefFoundError on the first
getDownloadsDirectory() call.

- assembleUpstreamJars now also ships plugin-path-utils-<ver>.jar, with the
  same dependsOn and rename trick as the other plugin-platform jars, and the
  floor on produced jars is 5, so a missing one fails the task.
- plugin-path-utils gets version 1.0.0, like plugin-api-ipc and
  plugin-api-core, so its jar has a versioned name the runtime can pin.
- The task KDoc and the release.yml comment list the fifth jar.

Before: 4 jars, none containing ai/rever/boss/plugin/pathutils. After: 5 jars,
plugin-path-utils-1.0.0.jar contains DownloadsDirectory.class and
WindowsDownloadsFolder.class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): refuse a Downloads path that cannot be resolved, and make the timeout test time itself

Review follow-up for this PR.

- isInsideDownloads fails closed: an IOException from canonicalFile or
  toRealPath now makes the check return false, so the plugin gets the
  SecurityException refusal instead of a generic I/O failure.
- realPath climbs with Files.exists(NOFOLLOW_LINKS), so a dangling symlink or
  junction inside Downloads is found as the link it is; toRealPath on it fails,
  and the write is refused instead of being checked as a plain name and then
  followed. The KDoc says so.
- The timeout test asserts the elapsed wall time is at least the timeout, so a
  child that fails at once (which also gives null) cannot pass it.
- A comment on the home branch of isReadableAndWritable says it compares
  canonicalFile paths and is knowingly weaker than the Downloads branch.
  Behaviour unchanged.

Seen red first: on the previous code both new RelocatedDownloadsAccessTest
cases fail (IOException: Invalid file path; FileNotFoundException through the
dangling junction), and the timeout test with a child that fails at once
fails after 124 ms, where the old version of the test passed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(auth): pin current passkey attempt and auth arguments (#1691)

* test(auth): pin current passkey attempt and arguments

* test(auth): preserve superseded attempt loading assertion

* fix(performance): clamp the plugin heap before bounding the initial heap with it (#1696)

PerformanceSettings.validated() bounded pluginJvmInitialHeapMb by the stored
pluginJvmHeapMb, not the clamped one. A stored max below 32 made the range
empty, coerceIn threw, and PerformanceSettingsManager's load fell back to
defaults, dropping every performance setting in the file over one bad value.
A stored max above 8192 let the initial heap end up above the clamped max.

Clamp the max heap first and bound the initial heap by that value.

Fixes #1000

* test(db): pin the viewer wrapper's page-size floor, and say LIMIT 0 changed (#1671 follow-up) (#1700)

#1671's re-review found the one clamp term its test left unpinned: GREATEST(..., 1) on
search_plugins_for_viewer's page size. Without it, the null and 101 cases still give 20
and 100, so a viewer-only typo there would ship. One assertion at 0 pins it, mirroring the
search_plugins block.

The page-size-0 case also gets a comment saying the behaviour changed. LIMIT 0 is legal, so
a direct call used to return an empty page with a correct total_count; it now returns one
row, matching get_popular_tags. The note lives in the test rather than in the merged
migration, which is already applied.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test: fail on skipped tests in every module and in the IDE, not only composeApp's Gradle run (#1699)

Review follow-up on #1667, which merged before these landed.

- The discovery-issue guard moves from composeApp's Test tasks to the root allprojects
  block, so every module's Gradle test run fails on a @test JUnit will not execute.
  Checked by adding a value-returning test to plugin-loader, whose run then failed with
  "must not return a value".
- composeApp repeats it in src/desktopTest/resources/junit-platform.properties, which
  JUnit reads however it is launched, so an IDE run meets it too. Checked with the root
  property removed: the same probe in composeApp still failed the run.
- The build comment says what a failure looks like (discovery aborts for the whole engine,
  and one initializationError names the method) and to relax the property to ERROR rather
  than delete it if a toolchain bump brings an unrelated warning.
- AGENTS.md records the rule beside the composeApp test-home note: declare an
  expression-bodied test `(): Unit =`, or write runBlocking<Unit>.
- McpApprovalGateTest's buffer test waits on pendingList instead of a 50 ms sleep, as its
  deny-all sibling already does. The unknown-surface test now observes the no-op: nothing
  appears, and a real surface survives.

All 29 modules' test tasks with the guard on: 8,414 tests, 0 failed. ktlint and detekt
clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugin-store): the #1508 tidy-up - one ceiling per bound, a real 400 body, paged routes bounded (#1669) (#1702)

All seven items from #1669, the follow-ups to #1508.

1. The popular-tags handler's Number.isInteger guard was dead: the route's schema validates
   before the handler runs, so `limit` is already an integer in 1..100. It is deleted, the
   handler reads `const { limit } = ctx.req.valid("query")`, and the NaN / LIMIT NULL
   rationale moves onto the schema. The handler's comment claimed an invalid limit still
   spent rate-limit budget, and /search's claimed the limit was consumed before parsing;
   both were untrue for the same reason and now say what happens.
2. browse gets a defaultHook, so a request that fails its schema is answered with the
   ErrorResponseSchema { error } every route declares, naming the field, rather than the
   validator's own { success: false, error: <ZodError> }.
3. Each ceiling is defined once in types/schemas.ts: POPULAR_TAGS_LIMIT_MAX,
   CATALOGUE_PAGE_SIZE_MAX, CATALOGUE_PAGE_MAX. The popular-tags query schema moves there as
   PopularTagsQuerySchema.
4. schemas.test.ts imports that schema instead of restating it, so moving the route's cap
   can no longer leave its tests green. It also pins the cap's edge.
5. /list declares its 400. catalogue-paging-bounds.test.ts checks /list?pageSize=999999999
   gets that 400 before the database.
6. Both routes bound `page` (1..10,000) and /search gains .int() on page and pageSize.
   A deeper page is a 400 instead of an empty page from the SQL clamp.
7. The popular-tags recorder keeps the RPC name and asserts get_popular_tags.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test(settings): fail on any settings or state writer that falls back to writeText (#1701)

A regression watch for #1022, which routed thirteen writers through atomicWriteText
(#1011, #1659) with a test for only one of them. Asked for when #1661 was closed as
superseded.

SettingsAtomicReplaceTest has one case per writer. Each saves through the API the app
uses and asserts the file got a new identity: a file rewritten in place keeps its inode,
and one replaced by rename gets a new one.

- The old inode is pinned with a hard link before the measured save, because ext4 hands a
  just-freed number to the next file created and a correct replace could compare equal.
  That happened once on ubuntu in #1661.
- The file is created first when absent, so a writer that loads on a background thread
  reads it rather than writing a default that would hide a reverted save.
- Installed plugins adds its own entry and removes it, leaving the shared installed.json
  as it found it. The HTML and plugin-state stores run on temp files.
- Windows exposes no fileKey, so the cases are skipped there with the reason.

Mutation-checked against a fresh test home: switching each of the thirteen files from
atomicWriteText to writeText fails exactly that file's case, 13 of 13. 1,181 tests across
the settings, plugin, html and filetypes suites pass; ktlint and detekt clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): make the corrupt-file aside helper non-throwing, and pin its collision path (#1704)

Follow-ups from the #938 merge review.

renameAsideCorrupt promised best-effort failure through its return value,
but toPath() throws InvalidPathException for a name the filesystem cannot
represent, and the move and the permission narrowing can throw
SecurityException. The three settings managers call it from recovery paths
their object initializers reach, so an escape failed the whole manager.
Both are now caught and logged, and the call returns false (#1692).

The stamp can be supplied, so the collision test pre-creates the first
aside and checks the retry to -1 keeps both copies, instead of relying on
two moves landing in one millisecond. A second test fills every suffix and
checks that nothing is overwritten (#1693).

The keymap Json comment now says that coercion is not a round trip: the
older build's next write persists the default, so a downgrade still loses
that one field, and a test pins it (#1694).

Fixes #1692
Fixes #1693
Fixes #1694

* fix(plugin-store): every router answers a schema failure with the 400 body it declares (#1705)

#1702 gave `browse` a defaultHook, so a request that fails its schema gets the
ErrorResponseSchema { error } its routes declare. The other five routers (publish,
api-keys, rating, download, admin) still answered with the validator's own
{ success: false, error: <ZodError> }. That body isn't the declared shape, and it hands the
caller the schema's internals. The #1702 review found this gap.

- utils/router.ts: newRouter() builds every plugin-store router with the hook and its
  invalidRequestMessage helper, moved out of browse.ts. It is a factory, not an app-level
  hook, because the hook is bound into a route's validators when router.openapi()
  registers it: a hook on the app in index.ts would never reach a sub-router's routes.
- All six routers use it.
- tests/router-hook.test.ts provokes one schema failure per router that has one (browse,
  rating, admin, api-keys, publish), using a client whose database and auth calls throw.
  Each must return 400 with only an `error` key, naming the field. download's routes take
  only path strings, which nothing fails, so a source scan covers it: no route file may
  construct OpenAPIHono directly, and the six routers must all be built by newRouter.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test: the #1699 and #1701 review follow-ups - buildSrc's guard, clearer settings-test failures (#1708)

Both PRs merged before their non-blocking review points could land.

#1699:
- buildSrc sets junit.platform.discovery.issue.severity.critical=WARNING itself. It is a
  separate build the root allprojects block never reaches, and its tests run in CI's own
  `./gradlew -p buildSrc test` step. All four buildSrc test classes pass with it on.
- The root comment and AGENTS.md say an override belongs in the module's own
  build.gradle.kts, which wins, and that buildSrc carries its own copy.

#1701:
- An unchanged identity is now reported as one of two things, told apart by the file's
  modified time: rewritten in place, or not written at all. The writers log a failed save
  and swallow it, so the old single message sent a reader after a writeText that wasn't
  there. Checked by forcing both.
- The class KDoc records that each measured save is assumed to write unconditionally.
- The default-apps case restores the test-home file and resets the manager, since it is
  the one case that changes shared state.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* build(buildSrc): pin JUnit to the root's version so the discovery-issue guard is live (#1709)

#1708 added junit.platform.discovery.issue.severity.critical=WARNING to buildSrc, and its
review asked whether that line was actually read. It wasn't. buildSrc's only test dependency
was kotlin("test"), which resolves JUnit 5.10.1 / Platform 1.10.1 through Gradle's embedded
Kotlin. The property arrived in Platform 1.13, so nothing read it: a value-returning @test
in buildSrc was still skipped with the build green.

- buildSrc/settings.gradle.kts imports the root version catalog.
- buildSrc/build.gradle.kts adds the JUnit BOM at libs.versions.junit.jupiter (6.1.3, the
  root's) and the platform launcher. Every JUnit artifact resolves to 6.1.3, and a later bump
  of the root moves buildSrc with it.

Checked both ways with a throwaway `fun probe() = 42` test in buildSrc. On dev it passed
silently (exit 0); with this change it fails with "must not return a value". buildSrc's own
43 tests pass, the root build configures, and ktlint and detekt are clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): record escalated shell calls in the ledger, and match format only as a command (#1655) (#1698)

* fix(mcp): record escalated shell calls in the ledger, and match format only as a command

Two of the #1655 leftovers the Linux seat testing still hits.

The ledger row of a CRITICAL shell call that a saved ALLOW did not cover
now carries escalated = true. Under YOLO mode such a call is answered
YOLO_ALLOWED with policyApplied = ASK, exactly like a routine one, so an
audit could not tell a destructive call that ran unattended from any
other. The field is hashed only when set, like secretRefs, so every
existing record keeps its hash; boss mcp ledger tail/search show it.

The registry states the escalation instead of inferring it from
policy != savedPolicy, so the prompt and the ledger row read one answer.

The evaluator matched the text "format " anywhere, which rated
--format json, clang-format and English typed through send_input
CRITICAL and made a saved Always Allow ask again for all of them. format
now counts only as the command word of a segment, or when followed by a
drive such as d:.

Refs #1655

* fix(mcp): address the #1698 review - format with leading switches, and the escalated flag's scope

- format with its switches before the volume (cmd /c format /q e:) rated
  HIGH, because only the token right after format was checked for a
  drive. The whole rest of the segment is scanned now; none of the false
  positives the change removed carries a drive letter.
- The escalated flag means the destructive-shell gate overrode a saved
  ALLOW, not that a call was destructive. Under the default ASK policy a
  destructive call YOLO answers records false. The record KDoc and
  AGENTS.md now say so, and a test pins that case. The KDoc also names
  the secret-policy case and the downgrade caveat for ledger verify.
- authorizeInvocation's escalated parameter loses its default, so a new
  caller has to answer.

* docs(agents): say the discovery-issue guard needs JUnit Platform 1.13, and why buildSrc pins the BOM (#1710)

Requested on #1709. junit.platform.discovery.issue.severity.critical is silently ignored on
JUnit Platform older than 1.13, and a passing run looks the same either way. buildSrc's BOM
pin (#1709) is what makes its guard live, so AGENTS.md now says so, and says how to check
a module with a throwaway value-returning test. That keeps a later "redundant dependency"
cleanup from quietly making the guard inert again.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(logging): log a failed decode by type and path, never with the file's content (#1629, #1695) (#1703)

* fix(logging): log a failed decode by type and path, never with the file's content

kotlinx puts the document it failed on into the exception message, and
even the diagnostic before it can quote a value. Logging those exceptions
as error = e wrote visited URLs with their query strings (recent pages,
#1629), the domains a user zoomed, and keymaps into the log that people
attach to bug reports (#1695).

decodeFailure(e) returns log fields with only the exception type, the
offset and the JSON path, with map keys masked since the zoom settings key
by domain. The recent-pages load and history bootstrap, the three
corrupt-file paths from #938 and the keymap import now log it instead of
the exception. SerializationException is caught ahead of the general
catch, which it would otherwise fall into as an IllegalArgumentException.

Fixes #1629
Refs #1695

* fix(logging): address the #1703 review - read markers from the diagnostic only, never throw

- The offset and path are read from the diagnostic, never from the
  document kotlinx appends after "JSON input:". The offset must lead the
  message, before anything kotlinx quotes, and an out-of-range number is
  dropped with toIntOrNull: a throw here ran inside the caller's catch
  and skipped its recovery.
- The path comes from the LAST marker on the first line, since kotlinx
  appends the genuine one after any value it quotes, and after masking it
  must be pure structure or it is left out. A marker spelled by a value
  or hidden in a key is not logged.
- Map keys are masked by index from the first [' to the last '], so a
  key holding '] or a Unicode line separator cannot end the mask early.
- The rest of the #1629 data and its neighbours: UrlHistoryManager (the
  browser-history.json that recent pages bootstraps from), ResolvedHostsStore,
  RecentFilesManager and SelfHealingSettingsManager's settings load now
  log decodeFailure(e) too.

* fix(logging): stop user_data.json decode failures logging the user's email and id

Review on #1703: UserDataStorage's two corrupt-record arms logged the
decoder's exception, whose message quotes the record - the user's email
and id. Both now log decodeFailure(e), and a test drives a torn record
through both paths and checks no entry carries the exception, the email
or the id.

Also from the review:
- A quoted value with a newline moved the genuine path off the first
  line, leaving the value's own marker last. An odd number of quotes
  before the marker means the line ends inside a value, so the path is
  left out.
- The offset scan is bounded to the start of the diagnostic.
- The KDoc says a non-identifier serial name drops the path too, and the
  space-in-key test comment describes the current masking.

* fix(logging): cover the last two user_data.json decodes

Review on #1703: loadUserData (the startup path, at ERROR) logged the
decoder's exception, and readStoredWizardFlag logged its toString(),
which is the same message by another route. Both quote the record, the
user's email and id. Both now log decodeFailure(e).

The torn-record test now drives all four decodes - loadUserData,
saveUserData's stored-flag read (at DEBUG), and the two wizard paths -
and checks each logs once with no exception, and no entry carries the
email or id.

Also: the odd-quote KDoc says it is a heuristic a value's own
apostrophes can defeat, bounded by the structural check; and the
AGENTS.md bullet forbids the decoder's message by any route and points
at #1711 for the older sites.

* fix(mcp): preserve legacy provider DENY rules (#1682)

* fix(mcp): keep a long argument from crashing the audit record of a call that already ran (#1712)

* fix(mcp): keep a long argument from crashing the audit record of a call that already ran

McpArgumentSanitizer.sanitizeMessage builds the ledger record inside invoke's
finally, after executeAuthorized has run, and the approval request before a
prompt. Its PEM rule repeated three GROUPS greedily - `(?:\s|\\n)*`, the
header-line body and the header lines themselves - and java.util.regex recurses
once per iteration of a greedy group repeat. `-----BEGIN PRIVATE KEY-----`
followed by 4,000 spaces overflows a default thread stack. The schema gate
ignores keys no schema declares, so a call under any ALLOW (saved rule, trusted
plugin, session trust, YOLO) could carry that payload next to its real command:
the tool ran, the ledger lost the row, no dropped-write was counted, and invoke
threw a StackOverflowError to its caller. With a prompt, the approval path
overflowed first and the call died unlogged. sensitiveAssignment's name suffix
`(?:[_-][A-Za-z0-9]+)*` overflows the same way on `password_a_a_a...`.

Every repeated group in the rules is now possessive (`*+`), which the engine
runs as a loop. Nothing that may follow any of those groups can be a character
the group consumed, so no match needs one of them to give anything back, and
the redactions are unchanged: the fuzz, auth-scheme, credential-shape, URI and
secret-reference suites pass as before.

sanitizeMessage also catches StackOverflowError and records
`[OMITTED: could not be sanitized]`: a backstop for a rule added later without
that care, which withholds the value rather than showing it raw.

McpArgumentSanitizerStackSafetyTest states the property: every rule's trigger
(25) followed by every pathological filler (18), at the ledger's 16,384-character
cap and at four times it, sanitizes on a 256 KB stack without throwing and
without reaching the backstop. Against dev it fails 62 cells, all
StackOverflowError, and both registry-level tests (a call run under ALLOW keeps
its ledger row; a prompted call reaches the operator and the ledger) fail with
the StackOverflowError escaping invoke.

AGENTS.md records the rule for anyone adding a sanitizer pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: leave AGENTS.md to the maintainers

The rule this change relies on is stated where it lives, in the KDoc beside
the code; AGENTS.md policy text is not merged from contributor PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kernel): check the requested action against the registered manifest before dispatch (#1716)

invokeCapability checked only that the target process was registered,
never that it advertised the requested action, while listCapabilities
advertised from the same registeredProcesses table - so the set the
kernel dispatched was strictly wider than the set it advertised. Check
the requested action against the registered process's own manifest at
the invokeCapability seam, before the broker is consulted:

- read registeredProcesses[pluginId] once;
- refuse "Process not found: <id>" when absent;
- refuse "Plugin <id> does not advertise capability: <action>" when the
  manifest has no matching action;
- only then hand the request to the broker. Advertise and dispatch now
  agree by construction.

Refusals go through one refuseCapabilityInvocation helper: the kernel
log copy is neutralized (IpcLogText.neutralize) because the reason
carries caller-supplied ids, while the response keeps the raw reason
for the authenticated caller. The verdict itself lives in a
capabilityAdmissionRefusal helper, which keeps the single registry
read and invokeCapability inside detekt's ReturnCount limit.

Four files:
- KernelServiceImpl.kt: the admission check and the refusal helper.
- KernelCapabilityAdmissionTest.kt (new): 5 refusal tests ported from
  #1363 to the new seam - unadvertised action, sibling-namespace
  action, respawn-with-fewer-capabilities, evicted-after-failure
  process, granted call dispatched with exactly the admitted pair -
  over the real authenticated transport, with a recording broker that
  is asserted never-consulted on refusals.
- KernelCapabilityRpcTest.kt: the unwired-broker test now registers
  capability("echo") so it pins the wiring refusal one step past
  admission.
- KernelLogForgingTest.kt: a refused invocation's log line cannot
  forge kernel log records.

Supersedes #1363, whose ProcessRegistryCapabilityResolver seam #1153
retired; the portable refusal tests are ported with credit preserved
per fluck-boss's 2026-09-25 13:23 UTC note.

* feat(browser): await quiescent browser calls (#1063)

Host-side BrowserHandle.awaitBrowserCallsQuiescent implementation and tests. Plugin-facing SDK release and boss-plugin-api pin bump tracked in #1726. Co-authored contribution remains with sidharth-vijayan through PR #1063.

* fix(db): the plugin store's name sort sorts by name, and its pages are stable (#1715)

* fix(db): the plugin store's name sort sorts by name, and its pages are stable

search_plugins_internal ordered by one sort_key, and that key had three defects, all
visible to a store client:

1. p_sort_by = 'name' sorted nothing: its key was the constant 0, so every row tied. The
   host sends 'name' for PluginSortOrder.NAME, and the local repository really sorts by
   displayName, so the same choice meant different things by source.
2. Ties were unordered for every sort, and LIMIT/OFFSET over an unstable order lets two
   pages of one listing repeat a plugin or skip one.
3. Every row's JSON was built before the offset: its version, rating, count, tag and
   organisation lookups ran for each row, then all but one page were thrown away.

The function is restated from 20260923133000 with only the ordering and the order of work
changed. Rows are ranked in a CTE by (lower(display_name) for 'name', sort_key DESC,
plugin_id), which is a total order because plugin_id is unique. Only the page is joined
back and projected. The filters, JSON keys, total_count, signature and ACL are unchanged.

search_plugins_order_test.sql uses five plugins whose plugin_id order differs from their
name order. It pins the name order, including on a second page, and that three pages over
five tied rows see each row exactly once. It also pins the unchanged count, the exact JSON
key set, the last and past-the-end pages, the ACL, and the name sort through the anon
wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(db): replace an assertion that could not fail; cover the filters on both copies

Review follow-up on #1715.

- The "same request twice" assertion compared one deterministic query with itself, so it
  would have passed against the pre-fix function too. It now asserts what it reached for:
  page 1 at size 2 is the first two rows of page 1 at size 5, which an unordered tie cannot
  promise.
- The filter block is written twice, once for total_count and once for the page, and no
  test in supabase/tests passed p_tags or p_verified_only. New assertions check a tag
  filter and verified-only on both the page and total_count, so a copy that drops a filter
  from one of them now fails.
- The migration header warns against folding the count into `ranked` as
  count(*) OVER (): on a page past the end `ranked` has no rows, so total_count would read
  0 instead of the number of matches.

plan(15) matches the fifteen assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ipc): build created and renamed paths in the proxy the way the host does (#1553)

FileSystemDataProviderProxy joined paths by hand ("$parent/$name") in
createFile, createFolder and rename, and returned that string to the plugin.
The in-process provider returns File(parent, name).absolutePath, so on Windows
the two disagreed on every call (mixed separators), and on every OS a parent
passed with a trailing separator produced a doubled one. rename of a path with
no parent also sent a bare name to the kernel, where the in-process provider
refuses with "Cannot determine parent directory".

FileSystemCreatedPathsTest drives the real proxy against a fake
FileSystemService: 5 cases, all failing on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cli): boss mcp ledger secrets - which tools received a credential, and when (#1733)

* feat(cli): boss mcp ledger secrets - which tools received a credential, and when

The ledger has recorded `secretRefs` on every governed call since #822, and
`boss mcp ledger tail|search` printed them per record, but nothing could answer
the question an operator asks before rotating a credential after an incident:
which tools and plugins received this secret, how often, and when last.

- `boss mcp ledger secrets` prints one summary per secret the matching calls
  referenced, most recently used first: calls that named it, how many handed it
  to a tool's handler and how many were withheld, the fields named, the tools
  (with counts) and plugins that asked, and the first, last and last-delivered
  times. `--json` gives the same as a machine-readable document.
- `--secret <id>` (any field of that secret) or `--secret <id>.<field>` (that
  field only) and `--provider <id>` filter `tail`, `search` and `secrets`. A
  selector that is not a secret id, or names a field a reference cannot, is
  refused rather than silently matching nothing; ids are matched case-folded, as
  the parser records them.
- "Delivered" is an exhaustive `when` over McpApprovalDisposition
  (`reachedHandler`), so a disposition added later is a compile error here
  rather than silently counted either way. Every approval disposition means the
  handler ran, because an approval withdrawn at the revocation or secret fence
  is recorded as POLICY_DENIED or SECRET_FORBIDDEN instead; CANCELLED_IN_FLIGHT
  had already started its handler, so it counts as delivered.

It reads only what the ledger records - references, never values - so it needs
no vault access and works with BOSS closed, like the other ledger actions. The
summary lives in its own McpLedgerSecrets object so McpLedgerCli keeps the size
its other actions need.

Tests: six in McpLedgerCliTest (delivered and withheld counted apart, one call
naming two fields counted once, a field selector, search by secret, field and
provider, the human report, selector validation, and "no secret" as an answer)
and two through Clikt in BossMcpCliTest (--file/--secret/--json end to end, and
a bad --secret failing with exit 1). docs/CLI.md documents the action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): let ledger --provider match a plugin, and say what the secrets report covers

Plugin-contributed tools record their provider as `<pluginId>::<providerId>`, so
`--provider secret-manager` matched nothing and an audit read "this plugin never received the
credential". A plugin id now matches every provider the plugin registered, and the scoped form
still matches one; the help text and docs say both.

- The human report labels provider ids `providers:` (the JSON key), prints which ledger it read,
  says a use older than the oldest rotated backup is not counted, and points at
  `boss mcp ledger verify`, on an empty report as well as a full one.
- Legacy CANCELLED, which predates the in-flight split, counts as a possible delivery: no record
  carrying a reference should have it, and for a credential audit over-reporting is the safe way
  to be wrong. The KDoc gives the dates that make it unreachable.
- A comment on the dotless-reference default, and the shadowing parameter renamed.

With the fixes reverted, 4 of 24 McpLedgerCliTest tests fail, including
`expected [vault_list, vault_get] but was []` for the plugin id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(logging): redact local decode failures (#1727)

* fix(chromium): cap archive extraction against a zip bomb (#1546)

* cap Chromium archive extraction against a zip bomb

Extraction now refuses an archive with too many entries or too many bytes, counted from what is actually read and named in the entries and never from ZipEntry.size. Directory entries and long paths draw from the same byte budget as file content, so they cannot create directories past it. The limits are parameters of extractWithJava with constant defaults. The macOS ditto path is unchanged.

* count backslash as a path separator in the extraction budget

Extraction runs on Windows, where a backslash in an entry name separates path components. Counting only the forward slash charged a deep backslash path as one component while it created one directory per segment. Both separators are counted now, with tests for the charge and for a deep backslash archive being refused before anything is created. Also corrects the limits comment: entries cost inodes, not file handles.

* Add owner-scoped RPCs for terminal session sharing (#1731)

* feat: add authenticated RPCs for terminal session sharing

* test: define terminal session RPC replacement contract

* feat: add terminal broadcast relay and account settings (#1734)

* feat: add terminal broadcast relay and account settings backend

* fix: harden relay admission accounting and review validation

* Fix relay queue boundaries and restore synchronization before production

* feat: restrict production relay credentials to ticket admission

* fix: return HTTP conflict for stale terminal preference revisions

* test: verify deployed terminal settings and relay admission

* test(git): make shell glob controls deterministic (#1737)

Co-authored-by: Gavin-Yau <2695188238@qq.com>

* fix(workspaces): stop an earlier session's Space set overruling the crash-recovery record (#1713)

* fix(workspaces): keep the Space set as fresh as the crash-recovery record, from one writer

Last_Session_Set.json was written only at a clean shutdown, and restore reads
it in preference to Last_Session.json. So after a session that restored a set,
worked and was then killed, the next launch brought back the clean shutdown
BEFORE it, and the watcher then wrote those older layouts over the only fresh
copy. Separately, every window's layout watcher wrote Last_Session.json, so a
secondary window's layout could replace the primary's recovery record: #19's
symptom by the in-session route, which LastSessionCoordinator only prevents at
shutdown.

LastSessionCoordinator.ownsSessionRecord(windowId) answers who may keep the
recovery files current during the session: the window a shutdown at that
moment would write for (the primary while it is open, else a live window, the
same choice saveOnProcessExit makes), and nobody while a live window is
protecting a refused restore. writeInSessionRecovery is the watcher's write:
the owner writes the record AND the set, built from the same liveSessionSet
expression the shutdown path uses; any other window writes neither. A window
running fewer than two Spaces removes an earlier set, as the coordinator does.

This replaces the earlier delete-on-first-record approach (reviewed on #1713):
there is no second writer of the set, no lock, no latch, and multi-Space crash
recovery survives restored tabs settling their titles instead of being retired
seconds after launch.

InSessionRecoveryTest drives the real coordinator and one WorkspaceManager per
"session" over a shared directory. With writeInSessionRecovery reduced to dev's
behaviour (every window writes the record, nothing refreshes the set) 3 of its
6 tests fail: the stale set wins, a secondary window writes, and a leftover set
outranks the record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): write the in-session recovery pair as one locked, uncancellable unit, set first

The owner's in-session write was two suspending writes, record then set. Closing a window
cancels its watcher before the coordinator hears of the close, so a cancellation between them
left a fresh Last_Session.json beside the stale Last_Session_Set.json that restore prefers: the
bug this change exists to fix, by another door. And the pair ran outside the shutdown write's
claim, so a Cmd+Q landing mid-pair could let the watcher's set land after the hook's, even
re-creating a set the hook had just deleted.

- LastSessionCoordinator.writeInSession: the pair is one blocking call, set first, under a lock
  the shutdown write also takes, and it re-checks the claim inside it, so a watcher arriving
  after the shutdown write adds nothing.
- writeInSessionRecovery runs it on Dispatchers.IO + NonCancellable.
- WorkspaceManager: the watcher's record write is split into a blocking file write and the list
  refresh, which stays on the caller's dispatcher; the suspend set helper is gone.
- Docs: LastSessionSet names the in-session path; ownsSessionRecord says a window closed
  mid-restore latches the same protection.
- Tests: a cancel mid-pair, a shutdown arriving mid-pair, a write after the shutdown write, and
  the refused-restore case now asserts the disk. Restored to the previous head's shape (record
  then set, two hops, no lock), 3 of the 8 fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): keep a failed in-session recovery write from ending the layout watcher

The review's one remaining item, plus the two clauses and the two tests it named.

- LastSessionCoordinator.writeInSession never throws, like claimAndWrite: the set's serialization
  runs outside the file write's own catch, and a throw from it reached the window's unsupervised
  layout watcher and ended it for the window's life, with no log line. It is now logged under
  LogCategory.WORKSPACE and answered false.
- writeInSessionRecovery keeps a set that cannot be built apart from a null set (which removes the
  file), logs it, and writes nothing, rather than letting it escape into the watcher.
- KDoc: the pair is whole because it is one non-suspending call (a cancel only lands at a
  suspension point); NonCancellable is what keeps a cancel before the IO dispatch from dropping
  the last change. writeLastSessionRecordBlocking says the list refresh is skipped when the caller
  was cancelled.
- Tests: a throwing write is answered false and the next write lands; an unbuildable set writes
  nothing; a cancel before the dispatch still records the change (pins NonCancellable); a write
  whose window closed while it waited for the lock is refused (pins the re-check inside it). With
  the four fixes reverted, exactly these four fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(keymap): show recovery notice after corrupt settings reset (#1725)

* fix(keymap): show recovery notice after corrupt settings reset

Record the preserved settings path before windows exist and offer a one-time reveal action after startup. Keep the public corrupt-file helper contract and cover preservation and delivery in regression tests.

Refs #1695

* fix(keymap): retain recovery notices until acknowledged

* fix(keymap): isolate startup helper to avoid JVM class clash

* fix(keymap): show preserved recovery path and remove stale imports

* fix(crash): swallow supabase-kt heartbeat/disconnect race instead of crashing (#1739) (#1740)

* fix(crash): swallow supabase-kt heartbeat/disconnect race instead of crashing (#1739)

supabase-kt 3.8.0 nulls the realtime socket before cancelling the
heartbeat job, so a per-tick sendHeartbeat child already in flight hits
the throwing websocket getter and escapes the library scope uncaught
(upstream #1394, fixed but unreleased by #1395). The heartbeat loop is
a SupervisorJob sibling and keeps ticking, and reconnect rebuilds the
socket, so one missed heartbeat must not pop the fatal crash dialog.

Extend CrashHandler.isIgnorable with the heartbeat-path sibling of the
existing stale-rejoin containment: exact message plus the two-frame
prefix RealtimeImpl.getWebsocket -> RealtimeImpl.sendHeartbeat, which
is unique to the tick child (sendHeartbeat is private upstream).
Ignorable-test fixtures come from the #1739 release-testing trace.

* refactor(crash): extract realtime containments, pin the two-frame boundary

detekt flagged CrashHandler as LargeClass with the new matcher, so both
supabase-kt realtime containments move to CrashIgnorableRealtime.kt,
each rule keeping its evidence comment. In the ignorable test, replace
the redundant app-frame lookalike (already covered by the renamed-
package assertion) with the cases that pin this rule's actual
boundary: an empty stack stays fatal, and an app frame past the
two-frame prefix stays ignorable by design.

* style(crash): blank line between header and first containment

---------

Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>

* fix(deep-link): stop logging Windows's expected APP_OPEN_URI gap as a warning (#1537)

Every Windows launch logged "Desktop.setOpenURIHandler not supported on
Windows" at WARN with an UnsupportedOperationException trace. The JDK
implements APP_OPEN_URI only in the macOS Desktop peer, so the call threw
on every launch, the handler it tried to register never ran, and the
comment above it ("might not work on all Windows versions") named no
version it did work on. boss:// links and file arguments reach the app
through argv instead, which processCommandLineArgs handles at cold start
and for the single-instance forward.

Remove the attempt and keep the registry setup, the one thing the Windows
branch has to do. #437 closed the same gap for the X11 peer by keeping the
call and logging the refusal at DEBUG; on Windows the call has no path to
success at all, so it goes rather than being quietened - the same reason
setupOpenFileHandler returns early on Windows.

A convention test guards the reintroduction: the macOS branch above and
the default branch below both register a handler, so adding it back to the
Windows branch reads as fixing an inconsistency, and the only symptom is a
log line on an OS no runner here uses.

* fix(db): stop a plugin's author setting its verified badge or moving it into another organisation (#1744)

`authenticated` holds table-level INSERT and UPDATE on public.plugins, and the
row policies let an author write their own plugin row. The policies gate the
row, not the columns in it, so an author could:

- set `verified` (the admin-only checkmark, written by the plugin-store admin
  route behind plugins.admin.publish) with a direct UPDATE, or INSERT a new row
  already verified, since the INSERT policy never checks the column;
- change `org_id` on their own plugin to any organisation, including one they
  do not administer: the UPDATE policy's `auth.uid() = author_id OR
  is_org_admin(org_id)` short-circuits on the author clause.

The migration replaces each client role's table-level grant with column-level
grants on every current column except `verified` (INSERT and UPDATE) and
`org_id` (UPDATE only; INSERT stays under the policy's can_publish_org_plugin
check), read from the catalog as 20260918000000 does, and fails closed if the
privilege survives through PUBLIC or an inherited role. The plugin-store edge
function uses the service-role key and is unaffected; nothing in this repo
writes the table as a client.

plugin_trust_columns_test.sql (13 assertions): on unpatched dev 7 fail, with
the verified UPDATE and INSERT and the foreign-org UPDATE all landing. Full
pgTAP suite: 50 files, 1151 tests, pass.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test(db): pin the exact plugins columns closed to clients, and say how the next one is closed (#1747)

The three non-blocking follow-ups fluck-boss left on #1744's merge.

- plugin_trust_columns_test.sql pins the closed set exactly: {verified, org_id} for UPDATE and
  {verified} for INSERT, for authenticated and anon. A column added to public.plugins is not
  client-writable until a migration grants it; now it fails the suite until someone decides
  whether clients may write it, instead of landing closed without anyone noticing. With a
  `featured` column added, the four new assertions fail (`have: {featured,org_id,verified}`).
- The migration header names the "Users with plugins.admin.publish can update any plugin" policy,
  so "the edge function is the one legitimate writer" can be checked against admin tools
  outside this repository.
- The header says the block cannot be copied to protect a third column: it acts only on a
  table-level grant, and none remains after it runs. With per-column grants, a later migration
  revokes the one column directly.

Comments only in the applied migration; its statements are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(spaces): resolve named-save ownership at press time (#1749)

* fix(dashboard): keep recent-file dismissals through startup loads (#1751)

* refactor(dashboard): generalize in-flight list load guard

* fix(dashboard): preserve file dismissals during startup

* test(dashboard): cover file dismissal load races

* fix(auth): redact passkey IDs and narrow route diagnostics (#1760)

* fix(logging): keep private local state out of decoder logs (#1762)

* fix(logging): redact local-state decode failures

* fix(workspaces): keep private layouts out of decode logs

* fix(plugins): replay window registration snapshots on restore (#1356)

* fix(plugins): replay window registration snapshots

* fix(plugin): prepare a registration before dropping its predecessor

register() removed the owner's live entry before running target.prepare;
both current prepares catch Throwable and the rest are identity, so nothing
throws today - but any future fallible prepare would strand the served
registration with nothing published. Preparing first keeps the old entry
serving on a throw, at no cost to the success path.

* test(plugins): preserve registration after snapshot failure

---------

Co-authored-by: Instinct Agent <agent@instinct.com>

* fix(editor): prevent stale navigation target replay (#1405)

* fix(editor): unify navigation target replay ownership

* test(editor): cover navigation delivery contract

* test(editor): isolate navigation IPC coverage by platform

* test(editor): cover runtime serialization and burst capacity

* fix(dashboard): require guarded list mutations (#1764)

* ⬆️ deps:(deps): bump kotlin from 2.4.10 to 2.4.20 (#654)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin.jvm
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.multiplatform
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.plugin.compose
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.plugin.serialization
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin:kotlin-test-junit
  dependency-version: 2.4.20
  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>

* fix: complete glass settings search after dev integration

* fix: handle recovery write failures and reject ZIP directory payloads

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: 43H1 <120793773+abhinavlenka@users.noreply.github.com>
Co-authored-by: abhinavlenka <abhinavlenka@users.noreply.github.com>
Co-authored-by: Nilay Toshniwal <142780771+nilaymastaadmi@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: knownshah <knownshah@gmail.com>
Co-authored-by: arjun28115 <singlaarjun28@gmail.com>
Co-authored-by: Abhiram Nair <abhipersonal656@gmail.com>
Co-authored-by: Atharv Sharma <atharvgaur591@gmail.com>
Co-authored-by: Rabari Mihir <mihirrabari2604@gmail.com>
Co-authored-by: Sidharth Vijayan <sidharthclt12@gmail.com>
Co-authored-by: Aroh Maurya <aroh3006@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
Co-authored-by: Qiu Guanzong <gavinyau@connect.hku.hk>
Co-authored-by: Gavin-Yau <2695188238@qq.com>
Co-authored-by: sebasidian sunday <sundaysebasidian@gmail.com>
Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>
Co-authored-by: Ansh Bal Dixit <68789929+anshbaldixit@users.noreply.github.com>
Co-authored-by: Sahil Singh Kushwah <sahilsinghkushwah10thb@gmail.com>
Co-authored-by: Aryan Raj <aryanraj2789@gmail.com>
Co-authored-by: Instinct Agent <agent@instinct.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
kshivang added a commit that referenced this pull request Oct 5, 2026
* fix(settings): recover corrupt keymap, zoom and workspace settings files, and lock zoom updates (#938)

Co-authored-by: abhinavlenka <abhinavlenka@users.noreply.github.com>

* fix(plugins): follow a Downloads folder the user moved on Windows

Windows lets the user move Downloads (Explorer, Properties, Location), for
instance to D:\Downloads, but BOSS kept answering %USERPROFILE%\Downloads.

- WindowsDownloadsFolder reads the shell's value for FOLDERID_Downloads with
  reg.exe, parses REG_SZ and REG_EXPAND_SZ, expands %NAME% variables and keeps
  only an absolute path. It needs no native binding, so the host and plugin
  processes read it the same way. Read once per JVM; a missing value, a
  missing reg.exe or a run past 5 s fall back to the old answer. It is only
  consulted when user.home is the Windows profile, so test tasks that
  redirect user.home never read the registry.
- DownloadsDirectory.current() passes it as the known folder, which still
  wins only when it names a real directory.
- FileSystemDataProviderImpl.writeFile/readFile admit the Downloads folder the
  provider hands out, and paths under it, in addition to home. Compared on
  real paths, so .., symlinks and junctions cannot lead into a sibling; a
  Downloads folder at a filesystem root admits nothing; delete stays
  home-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): log an unread Downloads known folder once, and make two reader tests test what they say

Review follow-up for this PR.

- The desktop getDefaultDownloadsDirectory logs once per JVM when the Windows
  known folder could not be read, so a failed registry read is no longer
  indistinguishable from a folder that was never moved. plugin-path-utils stays
  logger-free.
- The commonMain expect KDoc no longer says Windows is always
  %USERPROFILE%\Downloads.
- WindowsDownloadsFolder.current checks os.name itself instead of relying on
  its only caller to.
- "a successful run returns its standard output" ran java -version, which
  prints to stderr, so it passed on empty output; it now runs --version and
  checks the version is in what came back (a planted stdout-dropping mutation
  fails it). The timeout test uses a program that sleeps 60 s against a 1 s
  timeout instead of a 0 ms race.
- RelocatedDownloadsAccessTest's outside-home premise is a @BeforeTest
  assumption, so it gates every case instead of only itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(build): ship plugin-path-utils with the upstream jars plugin-api-ipc needs

plugin-api-ipc calls DownloadsDirectory from plugin-path-utils at runtime
(FileSystemDataProviderProxy), but assembleUpstreamJars shipped only four jars,
none of which contains it. The standalone microkernel runtime would compile
against plugin-api-ipc and then fail with NoClassDefFoundError on the first
getDownloadsDirectory() call.

- assembleUpstreamJars now also ships plugin-path-utils-<ver>.jar, with the
  same dependsOn and rename trick as the other plugin-platform jars, and the
  floor on produced jars is 5, so a missing one fails the task.
- plugin-path-utils gets version 1.0.0, like plugin-api-ipc and
  plugin-api-core, so its jar has a versioned name the runtime can pin.
- The task KDoc and the release.yml comment list the fifth jar.

Before: 4 jars, none containing ai/rever/boss/plugin/pathutils. After: 5 jars,
plugin-path-utils-1.0.0.jar contains DownloadsDirectory.class and
WindowsDownloadsFolder.class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): refuse a Downloads path that cannot be resolved, and make the timeout test time itself

Review follow-up for this PR.

- isInsideDownloads fails closed: an IOException from canonicalFile or
  toRealPath now makes the check return false, so the plugin gets the
  SecurityException refusal instead of a generic I/O failure.
- realPath climbs with Files.exists(NOFOLLOW_LINKS), so a dangling symlink or
  junction inside Downloads is found as the link it is; toRealPath on it fails,
  and the write is refused instead of being checked as a plain name and then
  followed. The KDoc says so.
- The timeout test asserts the elapsed wall time is at least the timeout, so a
  child that fails at once (which also gives null) cannot pass it.
- A comment on the home branch of isReadableAndWritable says it compares
  canonicalFile paths and is knowingly weaker than the Downloads branch.
  Behaviour unchanged.

Seen red first: on the previous code both new RelocatedDownloadsAccessTest
cases fail (IOException: Invalid file path; FileNotFoundException through the
dangling junction), and the timeout test with a child that fails at once
fails after 124 ms, where the old version of the test passed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(auth): pin current passkey attempt and auth arguments (#1691)

* test(auth): pin current passkey attempt and arguments

* test(auth): preserve superseded attempt loading assertion

* fix(performance): clamp the plugin heap before bounding the initial heap with it (#1696)

PerformanceSettings.validated() bounded pluginJvmInitialHeapMb by the stored
pluginJvmHeapMb, not the clamped one. A stored max below 32 made the range
empty, coerceIn threw, and PerformanceSettingsManager's load fell back to
defaults, dropping every performance setting in the file over one bad value.
A stored max above 8192 let the initial heap end up above the clamped max.

Clamp the max heap first and bound the initial heap by that value.

Fixes #1000

* test(db): pin the viewer wrapper's page-size floor, and say LIMIT 0 changed (#1671 follow-up) (#1700)

#1671's re-review found the one clamp term its test left unpinned: GREATEST(..., 1) on
search_plugins_for_viewer's page size. Without it, the null and 101 cases still give 20
and 100, so a viewer-only typo there would ship. One assertion at 0 pins it, mirroring the
search_plugins block.

The page-size-0 case also gets a comment saying the behaviour changed. LIMIT 0 is legal, so
a direct call used to return an empty page with a correct total_count; it now returns one
row, matching get_popular_tags. The note lives in the test rather than in the merged
migration, which is already applied.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test: fail on skipped tests in every module and in the IDE, not only composeApp's Gradle run (#1699)

Review follow-up on #1667, which merged before these landed.

- The discovery-issue guard moves from composeApp's Test tasks to the root allprojects
  block, so every module's Gradle test run fails on a @Test JUnit will not execute.
  Checked by adding a value-returning test to plugin-loader, whose run then failed with
  "must not return a value".
- composeApp repeats it in src/desktopTest/resources/junit-platform.properties, which
  JUnit reads however it is launched, so an IDE run meets it too. Checked with the root
  property removed: the same probe in composeApp still failed the run.
- The build comment says what a failure looks like (discovery aborts for the whole engine,
  and one initializationError names the method) and to relax the property to ERROR rather
  than delete it if a toolchain bump brings an unrelated warning.
- AGENTS.md records the rule beside the composeApp test-home note: declare an
  expression-bodied test `(): Unit =`, or write runBlocking<Unit>.
- McpApprovalGateTest's buffer test waits on pendingList instead of a 50 ms sleep, as its
  deny-all sibling already does. The unknown-surface test now observes the no-op: nothing
  appears, and a real surface survives.

All 29 modules' test tasks with the guard on: 8,414 tests, 0 failed. ktlint and detekt
clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugin-store): the #1508 tidy-up - one ceiling per bound, a real 400 body, paged routes bounded (#1669) (#1702)

All seven items from #1669, the follow-ups to #1508.

1. The popular-tags handler's Number.isInteger guard was dead: the route's schema validates
   before the handler runs, so `limit` is already an integer in 1..100. It is deleted, the
   handler reads `const { limit } = ctx.req.valid("query")`, and the NaN / LIMIT NULL
   rationale moves onto the schema. The handler's comment claimed an invalid limit still
   spent rate-limit budget, and /search's claimed the limit was consumed before parsing;
   both were untrue for the same reason and now say what happens.
2. browse gets a defaultHook, so a request that fails its schema is answered with the
   ErrorResponseSchema { error } every route declares, naming the field, rather than the
   validator's own { success: false, error: <ZodError> }.
3. Each ceiling is defined once in types/schemas.ts: POPULAR_TAGS_LIMIT_MAX,
   CATALOGUE_PAGE_SIZE_MAX, CATALOGUE_PAGE_MAX. The popular-tags query schema moves there as
   PopularTagsQuerySchema.
4. schemas.test.ts imports that schema instead of restating it, so moving the route's cap
   can no longer leave its tests green. It also pins the cap's edge.
5. /list declares its 400. catalogue-paging-bounds.test.ts checks /list?pageSize=999999999
   gets that 400 before the database.
6. Both routes bound `page` (1..10,000) and /search gains .int() on page and pageSize.
   A deeper page is a 400 instead of an empty page from the SQL clamp.
7. The popular-tags recorder keeps the RPC name and asserts get_popular_tags.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test(settings): fail on any settings or state writer that falls back to writeText (#1701)

A regression watch for #1022, which routed thirteen writers through atomicWriteText
(#1011, #1659) with a test for only one of them. Asked for when #1661 was closed as
superseded.

SettingsAtomicReplaceTest has one case per writer. Each saves through the API the app
uses and asserts the file got a new identity: a file rewritten in place keeps its inode,
and one replaced by rename gets a new one.

- The old inode is pinned with a hard link before the measured save, because ext4 hands a
  just-freed number to the next file created and a correct replace could compare equal.
  That happened once on ubuntu in #1661.
- The file is created first when absent, so a writer that loads on a background thread
  reads it rather than writing a default that would hide a reverted save.
- Installed plugins adds its own entry and removes it, leaving the shared installed.json
  as it found it. The HTML and plugin-state stores run on temp files.
- Windows exposes no fileKey, so the cases are skipped there with the reason.

Mutation-checked against a fresh test home: switching each of the thirteen files from
atomicWriteText to writeText fails exactly that file's case, 13 of 13. 1,181 tests across
the settings, plugin, html and filetypes suites pass; ktlint and detekt clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): make the corrupt-file aside helper non-throwing, and pin its collision path (#1704)

Follow-ups from the #938 merge review.

renameAsideCorrupt promised best-effort failure through its return value,
but toPath() throws InvalidPathException for a name the filesystem cannot
represent, and the move and the permission narrowing can throw
SecurityException. The three settings managers call it from recovery paths
their object initializers reach, so an escape failed the whole manager.
Both are now caught and logged, and the call returns false (#1692).

The stamp can be supplied, so the collision test pre-creates the first
aside and checks the retry to -1 keeps both copies, instead of relying on
two moves landing in one millisecond. A second test fills every suffix and
checks that nothing is overwritten (#1693).

The keymap Json comment now says that coercion is not a round trip: the
older build's next write persists the default, so a downgrade still loses
that one field, and a test pins it (#1694).

Fixes #1692
Fixes #1693
Fixes #1694

* fix(plugin-store): every router answers a schema failure with the 400 body it declares (#1705)

#1702 gave `browse` a defaultHook, so a request that fails its schema gets the
ErrorResponseSchema { error } its routes declare. The other five routers (publish,
api-keys, rating, download, admin) still answered with the validator's own
{ success: false, error: <ZodError> }. That body isn't the declared shape, and it hands the
caller the schema's internals. The #1702 review found this gap.

- utils/router.ts: newRouter() builds every plugin-store router with the hook and its
  invalidRequestMessage helper, moved out of browse.ts. It is a factory, not an app-level
  hook, because the hook is bound into a route's validators when router.openapi()
  registers it: a hook on the app in index.ts would never reach a sub-router's routes.
- All six routers use it.
- tests/router-hook.test.ts provokes one schema failure per router that has one (browse,
  rating, admin, api-keys, publish), using a client whose database and auth calls throw.
  Each must return 400 with only an `error` key, naming the field. download's routes take
  only path strings, which nothing fails, so a source scan covers it: no route file may
  construct OpenAPIHono directly, and the six routers must all be built by newRouter.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test: the #1699 and #1701 review follow-ups - buildSrc's guard, clearer settings-test failures (#1708)

Both PRs merged before their non-blocking review points could land.

#1699:
- buildSrc sets junit.platform.discovery.issue.severity.critical=WARNING itself. It is a
  separate build the root allprojects block never reaches, and its tests run in CI's own
  `./gradlew -p buildSrc test` step. All four buildSrc test classes pass with it on.
- The root comment and AGENTS.md say an override belongs in the module's own
  build.gradle.kts, which wins, and that buildSrc carries its own copy.

#1701:
- An unchanged identity is now reported as one of two things, told apart by the file's
  modified time: rewritten in place, or not written at all. The writers log a failed save
  and swallow it, so the old single message sent a reader after a writeText that wasn't
  there. Checked by forcing both.
- The class KDoc records that each measured save is assumed to write unconditionally.
- The default-apps case restores the test-home file and resets the manager, since it is
  the one case that changes shared state.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* build(buildSrc): pin JUnit to the root's version so the discovery-issue guard is live (#1709)

#1708 added junit.platform.discovery.issue.severity.critical=WARNING to buildSrc, and its
review asked whether that line was actually read. It wasn't. buildSrc's only test dependency
was kotlin("test"), which resolves JUnit 5.10.1 / Platform 1.10.1 through Gradle's embedded
Kotlin. The property arrived in Platform 1.13, so nothing read it: a value-returning @Test
in buildSrc was still skipped with the build green.

- buildSrc/settings.gradle.kts imports the root version catalog.
- buildSrc/build.gradle.kts adds the JUnit BOM at libs.versions.junit.jupiter (6.1.3, the
  root's) and the platform launcher. Every JUnit artifact resolves to 6.1.3, and a later bump
  of the root moves buildSrc with it.

Checked both ways with a throwaway `fun probe() = 42` test in buildSrc. On dev it passed
silently (exit 0); with this change it fails with "must not return a value". buildSrc's own
43 tests pass, the root build configures, and ktlint and detekt are clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): record escalated shell calls in the ledger, and match format only as a command (#1655) (#1698)

* fix(mcp): record escalated shell calls in the ledger, and match format only as a command

Two of the #1655 leftovers the Linux seat testing still hits.

The ledger row of a CRITICAL shell call that a saved ALLOW did not cover
now carries escalated = true. Under YOLO mode such a call is answered
YOLO_ALLOWED with policyApplied = ASK, exactly like a routine one, so an
audit could not tell a destructive call that ran unattended from any
other. The field is hashed only when set, like secretRefs, so every
existing record keeps its hash; boss mcp ledger tail/search show it.

The registry states the escalation instead of inferring it from
policy != savedPolicy, so the prompt and the ledger row read one answer.

The evaluator matched the text "format " anywhere, which rated
--format json, clang-format and English typed through send_input
CRITICAL and made a saved Always Allow ask again for all of them. format
now counts only as the command word of a segment, or when followed by a
drive such as d:.

Refs #1655

* fix(mcp): address the #1698 review - format with leading switches, and the escalated flag's scope

- format with its switches before the volume (cmd /c format /q e:) rated
  HIGH, because only the token right after format was checked for a
  drive. The whole rest of the segment is scanned now; none of the false
  positives the change removed carries a drive letter.
- The escalated flag means the destructive-shell gate overrode a saved
  ALLOW, not that a call was destructive. Under the default ASK policy a
  destructive call YOLO answers records false. The record KDoc and
  AGENTS.md now say so, and a test pins that case. The KDoc also names
  the secret-policy case and the downgrade caveat for ledger verify.
- authorizeInvocation's escalated parameter loses its default, so a new
  caller has to answer.

* docs(agents): say the discovery-issue guard needs JUnit Platform 1.13, and why buildSrc pins the BOM (#1710)

Requested on #1709. junit.platform.discovery.issue.severity.critical is silently ignored on
JUnit Platform older than 1.13, and a passing run looks the same either way. buildSrc's BOM
pin (#1709) is what makes its guard live, so AGENTS.md now says so, and says how to check
a module with a throwaway value-returning test. That keeps a later "redundant dependency"
cleanup from quietly making the guard inert again.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(logging): log a failed decode by type and path, never with the file's content (#1629, #1695) (#1703)

* fix(logging): log a failed decode by type and path, never with the file's content

kotlinx puts the document it failed on into the exception message, and
even the diagnostic before it can quote a value. Logging those exceptions
as error = e wrote visited URLs with their query strings (recent pages,
#1629), the domains a user zoomed, and keymaps into the log that people
attach to bug reports (#1695).

decodeFailure(e) returns log fields with only the exception type, the
offset and the JSON path, with map keys masked since the zoom settings key
by domain. The recent-pages load and history bootstrap, the three
corrupt-file paths from #938 and the keymap import now log it instead of
the exception. SerializationException is caught ahead of the general
catch, which it would otherwise fall into as an IllegalArgumentException.

Fixes #1629
Refs #1695

* fix(logging): address the #1703 review - read markers from the diagnostic only, never throw

- The offset and path are read from the diagnostic, never from the
  document kotlinx appends after "JSON input:". The offset must lead the
  message, before anything kotlinx quotes, and an out-of-range number is
  dropped with toIntOrNull: a throw here ran inside the caller's catch
  and skipped its recovery.
- The path comes from the LAST marker on the first line, since kotlinx
  appends the genuine one after any value it quotes, and after masking it
  must be pure structure or it is left out. A marker spelled by a value
  or hidden in a key is not logged.
- Map keys are masked by index from the first [' to the last '], so a
  key holding '] or a Unicode line separator cannot end the mask early.
- The rest of the #1629 data and its neighbours: UrlHistoryManager (the
  browser-history.json that recent pages bootstraps from), ResolvedHostsStore,
  RecentFilesManager and SelfHealingSettingsManager's settings load now
  log decodeFailure(e) too.

* fix(logging): stop user_data.json decode failures logging the user's email and id

Review on #1703: UserDataStorage's two corrupt-record arms logged the
decoder's exception, whose message quotes the record - the user's email
and id. Both now log decodeFailure(e), and a test drives a torn record
through both paths and checks no entry carries the exception, the email
or the id.

Also from the review:
- A quoted value with a newline moved the genuine path off the first
  line, leaving the value's own marker last. An odd number of quotes
  before the marker means the line ends inside a value, so the path is
  left out.
- The offset scan is bounded to the start of the diagnostic.
- The KDoc says a non-identifier serial name drops the path too, and the
  space-in-key test comment describes the current masking.

* fix(logging): cover the last two user_data.json decodes

Review on #1703: loadUserData (the startup path, at ERROR) logged the
decoder's exception, and readStoredWizardFlag logged its toString(),
which is the same message by another route. Both quote the record, the
user's email and id. Both now log decodeFailure(e).

The torn-record test now drives all four decodes - loadUserData,
saveUserData's stored-flag read (at DEBUG), and the two wizard paths -
and checks each logs once with no exception, and no entry carries the
email or id.

Also: the odd-quote KDoc says it is a heuristic a value's own
apostrophes can defeat, bounded by the structural check; and the
AGENTS.md bullet forbids the decoder's message by any route and points
at #1711 for the older sites.

* fix(mcp): preserve legacy provider DENY rules (#1682)

* fix(mcp): keep a long argument from crashing the audit record of a call that already ran (#1712)

* fix(mcp): keep a long argument from crashing the audit record of a call that already ran

McpArgumentSanitizer.sanitizeMessage builds the ledger record inside invoke's
finally, after executeAuthorized has run, and the approval request before a
prompt. Its PEM rule repeated three GROUPS greedily - `(?:\s|\\n)*`, the
header-line body and the header lines themselves - and java.util.regex recurses
once per iteration of a greedy group repeat. `-----BEGIN PRIVATE KEY-----`
followed by 4,000 spaces overflows a default thread stack. The schema gate
ignores keys no schema declares, so a call under any ALLOW (saved rule, trusted
plugin, session trust, YOLO) could carry that payload next to its real command:
the tool ran, the ledger lost the row, no dropped-write was counted, and invoke
threw a StackOverflowError to its caller. With a prompt, the approval path
overflowed first and the call died unlogged. sensitiveAssignment's name suffix
`(?:[_-][A-Za-z0-9]+)*` overflows the same way on `password_a_a_a...`.

Every repeated group in the rules is now possessive (`*+`), which the engine
runs as a loop. Nothing that may follow any of those groups can be a character
the group consumed, so no match needs one of them to give anything back, and
the redactions are unchanged: the fuzz, auth-scheme, credential-shape, URI and
secret-reference suites pass as before.

sanitizeMessage also catches StackOverflowError and records
`[OMITTED: could not be sanitized]`: a backstop for a rule added later without
that care, which withholds the value rather than showing it raw.

McpArgumentSanitizerStackSafetyTest states the property: every rule's trigger
(25) followed by every pathological filler (18), at the ledger's 16,384-character
cap and at four times it, sanitizes on a 256 KB stack without throwing and
without reaching the backstop. Against dev it fails 62 cells, all
StackOverflowError, and both registry-level tests (a call run under ALLOW keeps
its ledger row; a prompted call reaches the operator and the ledger) fail with
the StackOverflowError escaping invoke.

AGENTS.md records the rule for anyone adding a sanitizer pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: leave AGENTS.md to the maintainers

The rule this change relies on is stated where it lives, in the KDoc beside
the code; AGENTS.md policy text is not merged from contributor PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kernel): check the requested action against the registered manifest before dispatch (#1716)

invokeCapability checked only that the target process was registered,
never that it advertised the requested action, while listCapabilities
advertised from the same registeredProcesses table - so the set the
kernel dispatched was strictly wider than the set it advertised. Check
the requested action against the registered process's own manifest at
the invokeCapability seam, before the broker is consulted:

- read registeredProcesses[pluginId] once;
- refuse "Process not found: <id>" when absent;
- refuse "Plugin <id> does not advertise capability: <action>" when the
  manifest has no matching action;
- only then hand the request to the broker. Advertise and dispatch now
  agree by construction.

Refusals go through one refuseCapabilityInvocation helper: the kernel
log copy is neutralized (IpcLogText.neutralize) because the reason
carries caller-supplied ids, while the response keeps the raw reason
for the authenticated caller. The verdict itself lives in a
capabilityAdmissionRefusal helper, which keeps the single registry
read and invokeCapability inside detekt's ReturnCount limit.

Four files:
- KernelServiceImpl.kt: the admission check and the refusal helper.
- KernelCapabilityAdmissionTest.kt (new): 5 refusal tests ported from
  #1363 to the new seam - unadvertised action, sibling-namespace
  action, respawn-with-fewer-capabilities, evicted-after-failure
  process, granted call dispatched with exactly the admitted pair -
  over the real authenticated transport, with a recording broker that
  is asserted never-consulted on refusals.
- KernelCapabilityRpcTest.kt: the unwired-broker test now registers
  capability("echo") so it pins the wiring refusal one step past
  admission.
- KernelLogForgingTest.kt: a refused invocation's log line cannot
  forge kernel log records.

Supersedes #1363, whose ProcessRegistryCapabilityResolver seam #1153
retired; the portable refusal tests are ported with credit preserved
per fluck-boss's 2026-09-25 13:23 UTC note.

* feat(browser): await quiescent browser calls (#1063)

Host-side BrowserHandle.awaitBrowserCallsQuiescent implementation and tests. Plugin-facing SDK release and boss-plugin-api pin bump tracked in #1726. Co-authored contribution remains with sidharth-vijayan through PR #1063.

* fix(db): the plugin store's name sort sorts by name, and its pages are stable (#1715)

* fix(db): the plugin store's name sort sorts by name, and its pages are stable

search_plugins_internal ordered by one sort_key, and that key had three defects, all
visible to a store client:

1. p_sort_by = 'name' sorted nothing: its key was the constant 0, so every row tied. The
   host sends 'name' for PluginSortOrder.NAME, and the local repository really sorts by
   displayName, so the same choice meant different things by source.
2. Ties were unordered for every sort, and LIMIT/OFFSET over an unstable order lets two
   pages of one listing repeat a plugin or skip one.
3. Every row's JSON was built before the offset: its version, rating, count, tag and
   organisation lookups ran for each row, then all but one page were thrown away.

The function is restated from 20260923133000 with only the ordering and the order of work
changed. Rows are ranked in a CTE by (lower(display_name) for 'name', sort_key DESC,
plugin_id), which is a total order because plugin_id is unique. Only the page is joined
back and projected. The filters, JSON keys, total_count, signature and ACL are unchanged.

search_plugins_order_test.sql uses five plugins whose plugin_id order differs from their
name order. It pins the name order, including on a second page, and that three pages over
five tied rows see each row exactly once. It also pins the unchanged count, the exact JSON
key set, the last and past-the-end pages, the ACL, and the name sort through the anon
wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(db): replace an assertion that could not fail; cover the filters on both copies

Review follow-up on #1715.

- The "same request twice" assertion compared one deterministic query with itself, so it
  would have passed against the pre-fix function too. It now asserts what it reached for:
  page 1 at size 2 is the first two rows of page 1 at size 5, which an unordered tie cannot
  promise.
- The filter block is written twice, once for total_count and once for the page, and no
  test in supabase/tests passed p_tags or p_verified_only. New assertions check a tag
  filter and verified-only on both the page and total_count, so a copy that drops a filter
  from one of them now fails.
- The migration header warns against folding the count into `ranked` as
  count(*) OVER (): on a page past the end `ranked` has no rows, so total_count would read
  0 instead of the number of matches.

plan(15) matches the fifteen assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ipc): build created and renamed paths in the proxy the way the host does (#1553)

FileSystemDataProviderProxy joined paths by hand ("$parent/$name") in
createFile, createFolder and rename, and returned that string to the plugin.
The in-process provider returns File(parent, name).absolutePath, so on Windows
the two disagreed on every call (mixed separators), and on every OS a parent
passed with a trailing separator produced a doubled one. rename of a path with
no parent also sent a bare name to the kernel, where the in-process provider
refuses with "Cannot determine parent directory".

FileSystemCreatedPathsTest drives the real proxy against a fake
FileSystemService: 5 cases, all failing on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cli): boss mcp ledger secrets - which tools received a credential, and when (#1733)

* feat(cli): boss mcp ledger secrets - which tools received a credential, and when

The ledger has recorded `secretRefs` on every governed call since #822, and
`boss mcp ledger tail|search` printed them per record, but nothing could answer
the question an operator asks before rotating a credential after an incident:
which tools and plugins received this secret, how often, and when last.

- `boss mcp ledger secrets` prints one summary per secret the matching calls
  referenced, most recently used first: calls that named it, how many handed it
  to a tool's handler and how many were withheld, the fields named, the tools
  (with counts) and plugins that asked, and the first, last and last-delivered
  times. `--json` gives the same as a machine-readable document.
- `--secret <id>` (any field of that secret) or `--secret <id>.<field>` (that
  field only) and `--provider <id>` filter `tail`, `search` and `secrets`. A
  selector that is not a secret id, or names a field a reference cannot, is
  refused rather than silently matching nothing; ids are matched case-folded, as
  the parser records them.
- "Delivered" is an exhaustive `when` over McpApprovalDisposition
  (`reachedHandler`), so a disposition added later is a compile error here
  rather than silently counted either way. Every approval disposition means the
  handler ran, because an approval withdrawn at the revocation or secret fence
  is recorded as POLICY_DENIED or SECRET_FORBIDDEN instead; CANCELLED_IN_FLIGHT
  had already started its handler, so it counts as delivered.

It reads only what the ledger records - references, never values - so it needs
no vault access and works with BOSS closed, like the other ledger actions. The
summary lives in its own McpLedgerSecrets object so McpLedgerCli keeps the size
its other actions need.

Tests: six in McpLedgerCliTest (delivered and withheld counted apart, one call
naming two fields counted once, a field selector, search by secret, field and
provider, the human report, selector validation, and "no secret" as an answer)
and two through Clikt in BossMcpCliTest (--file/--secret/--json end to end, and
a bad --secret failing with exit 1). docs/CLI.md documents the action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): let ledger --provider match a plugin, and say what the secrets report covers

Plugin-contributed tools record their provider as `<pluginId>::<providerId>`, so
`--provider secret-manager` matched nothing and an audit read "this plugin never received the
credential". A plugin id now matches every provider the plugin registered, and the scoped form
still matches one; the help text and docs say both.

- The human report labels provider ids `providers:` (the JSON key), prints which ledger it read,
  says a use older than the oldest rotated backup is not counted, and points at
  `boss mcp ledger verify`, on an empty report as well as a full one.
- Legacy CANCELLED, which predates the in-flight split, counts as a possible delivery: no record
  carrying a reference should have it, and for a credential audit over-reporting is the safe way
  to be wrong. The KDoc gives the dates that make it unreachable.
- A comment on the dotless-reference default, and the shadowing parameter renamed.

With the fixes reverted, 4 of 24 McpLedgerCliTest tests fail, including
`expected [vault_list, vault_get] but was []` for the plugin id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(logging): redact local decode failures (#1727)

* fix(chromium): cap archive extraction against a zip bomb (#1546)

* cap Chromium archive extraction against a zip bomb

Extraction now refuses an archive with too many entries or too many bytes, counted from what is actually read and named in the entries and never from ZipEntry.size. Directory entries and long paths draw from the same byte budget as file content, so they cannot create directories past it. The limits are parameters of extractWithJava with constant defaults. The macOS ditto path is unchanged.

* count backslash as a path separator in the extraction budget

Extraction runs on Windows, where a backslash in an entry name separates path components. Counting only the forward slash charged a deep backslash path as one component while it created one directory per segment. Both separators are counted now, with tests for the charge and for a deep backslash archive being refused before anything is created. Also corrects the limits comment: entries cost inodes, not file handles.

* Add owner-scoped RPCs for terminal session sharing (#1731)

* feat: add authenticated RPCs for terminal session sharing

* test: define terminal session RPC replacement contract

* feat: add terminal broadcast relay and account settings (#1734)

* feat: add terminal broadcast relay and account settings backend

* fix: harden relay admission accounting and review validation

* Fix relay queue boundaries and restore synchronization before production

* feat: restrict production relay credentials to ticket admission

* fix: return HTTP conflict for stale terminal preference revisions

* test: verify deployed terminal settings and relay admission

* test(git): make shell glob controls deterministic (#1737)

Co-authored-by: Gavin-Yau <2695188238@qq.com>

* fix(workspaces): stop an earlier session's Space set overruling the crash-recovery record (#1713)

* fix(workspaces): keep the Space set as fresh as the crash-recovery record, from one writer

Last_Session_Set.json was written only at a clean shutdown, and restore reads
it in preference to Last_Session.json. So after a session that restored a set,
worked and was then killed, the next launch brought back the clean shutdown
BEFORE it, and the watcher then wrote those older layouts over the only fresh
copy. Separately, every window's layout watcher wrote Last_Session.json, so a
secondary window's layout could replace the primary's recovery record: #19's
symptom by the in-session route, which LastSessionCoordinator only prevents at
shutdown.

LastSessionCoordinator.ownsSessionRecord(windowId) answers who may keep the
recovery files current during the session: the window a shutdown at that
moment would write for (the primary while it is open, else a live window, the
same choice saveOnProcessExit makes), and nobody while a live window is
protecting a refused restore. writeInSessionRecovery is the watcher's write:
the owner writes the record AND the set, built from the same liveSessionSet
expression the shutdown path uses; any other window writes neither. A window
running fewer than two Spaces removes an earlier set, as the coordinator does.

This replaces the earlier delete-on-first-record approach (reviewed on #1713):
there is no second writer of the set, no lock, no latch, and multi-Space crash
recovery survives restored tabs settling their titles instead of being retired
seconds after launch.

InSessionRecoveryTest drives the real coordinator and one WorkspaceManager per
"session" over a shared directory. With writeInSessionRecovery reduced to dev's
behaviour (every window writes the record, nothing refreshes the set) 3 of its
6 tests fail: the stale set wins, a secondary window writes, and a leftover set
outranks the record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): write the in-session recovery pair as one locked, uncancellable unit, set first

The owner's in-session write was two suspending writes, record then set. Closing a window
cancels its watcher before the coordinator hears of the close, so a cancellation between them
left a fresh Last_Session.json beside the stale Last_Session_Set.json that restore prefers: the
bug this change exists to fix, by another door. And the pair ran outside the shutdown write's
claim, so a Cmd+Q landing mid-pair could let the watcher's set land after the hook's, even
re-creating a set the hook had just deleted.

- LastSessionCoordinator.writeInSession: the pair is one blocking call, set first, under a lock
  the shutdown write also takes, and it re-checks the claim inside it, so a watcher arriving
  after the shutdown write adds nothing.
- writeInSessionRecovery runs it on Dispatchers.IO + NonCancellable.
- WorkspaceManager: the watcher's record write is split into a blocking file write and the list
  refresh, which stays on the caller's dispatcher; the suspend set helper is gone.
- Docs: LastSessionSet names the in-session path; ownsSessionRecord says a window closed
  mid-restore latches the same protection.
- Tests: a cancel mid-pair, a shutdown arriving mid-pair, a write after the shutdown write, and
  the refused-restore case now asserts the disk. Restored to the previous head's shape (record
  then set, two hops, no lock), 3 of the 8 fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): keep a failed in-session recovery write from ending the layout watcher

The review's one remaining item, plus the two clauses and the two tests it named.

- LastSessionCoordinator.writeInSession never throws, like claimAndWrite: the set's serialization
  runs outside the file write's own catch, and a throw from it reached the window's unsupervised
  layout watcher and ended it for the window's life, with no log line. It is now logged under
  LogCategory.WORKSPACE and answered false.
- writeInSessionRecovery keeps a set that cannot be built apart from a null set (which removes the
  file), logs it, and writes nothing, rather than letting it escape into the watcher.
- KDoc: the pair is whole because it is one non-suspending call (a cancel only lands at a
  suspension point); NonCancellable is what keeps a cancel before the IO dispatch from dropping
  the last change. writeLastSessionRecordBlocking says the list refresh is skipped when the caller
  was cancelled.
- Tests: a throwing write is answered false and the next write lands; an unbuildable set writes
  nothing; a cancel before the dispatch still records the change (pins NonCancellable); a write
  whose window closed while it waited for the lock is refused (pins the re-check inside it). With
  the four fixes reverted, exactly these four fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(keymap): show recovery notice after corrupt settings reset (#1725)

* fix(keymap): show recovery notice after corrupt settings reset

Record the preserved settings path before windows exist and offer a one-time reveal action after startup. Keep the public corrupt-file helper contract and cover preservation and delivery in regression tests.

Refs #1695

* fix(keymap): retain recovery notices until acknowledged

* fix(keymap): isolate startup helper to avoid JVM class clash

* fix(keymap): show preserved recovery path and remove stale imports

* fix(crash): swallow supabase-kt heartbeat/disconnect race instead of crashing (#1739) (#1740)

* fix(crash): swallow supabase-kt heartbeat/disconnect race instead of crashing (#1739)

supabase-kt 3.8.0 nulls the realtime socket before cancelling the
heartbeat job, so a per-tick sendHeartbeat child already in flight hits
the throwing websocket getter and escapes the library scope uncaught
(upstream #1394, fixed but unreleased by #1395). The heartbeat loop is
a SupervisorJob sibling and keeps ticking, and reconnect rebuilds the
socket, so one missed heartbeat must not pop the fatal crash dialog.

Extend CrashHandler.isIgnorable with the heartbeat-path sibling of the
existing stale-rejoin containment: exact message plus the two-frame
prefix RealtimeImpl.getWebsocket -> RealtimeImpl.sendHeartbeat, which
is unique to the tick child (sendHeartbeat is private upstream).
Ignorable-test fixtures come from the #1739 release-testing trace.

* refactor(crash): extract realtime containments, pin the two-frame boundary

detekt flagged CrashHandler as LargeClass with the new matcher, so both
supabase-kt realtime containments move to CrashIgnorableRealtime.kt,
each rule keeping its evidence comment. In the ignorable test, replace
the redundant app-frame lookalike (already covered by the renamed-
package assertion) with the cases that pin this rule's actual
boundary: an empty stack stays fatal, and an app frame past the
two-frame prefix stays ignorable by design.

* style(crash): blank line between header and first containment

---------

Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>

* fix(deep-link): stop logging Windows's expected APP_OPEN_URI gap as a warning (#1537)

Every Windows launch logged "Desktop.setOpenURIHandler not supported on
Windows" at WARN with an UnsupportedOperationException trace. The JDK
implements APP_OPEN_URI only in the macOS Desktop peer, so the call threw
on every launch, the handler it tried to register never ran, and the
comment above it ("might not work on all Windows versions") named no
version it did work on. boss:// links and file arguments reach the app
through argv instead, which processCommandLineArgs handles at cold start
and for the single-instance forward.

Remove the attempt and keep the registry setup, the one thing the Windows
branch has to do. #437 closed the same gap for the X11 peer by keeping the
call and logging the refusal at DEBUG; on Windows the call has no path to
success at all, so it goes rather than being quietened - the same reason
setupOpenFileHandler returns early on Windows.

A convention test guards the reintroduction: the macOS branch above and
the default branch below both register a handler, so adding it back to the
Windows branch reads as fixing an inconsistency, and the only symptom is a
log line on an OS no runner here uses.

* fix(db): stop a plugin's author setting its verified badge or moving it into another organisation (#1744)

`authenticated` holds table-level INSERT and UPDATE on public.plugins, and the
row policies let an author write their own plugin row. The policies gate the
row, not the columns in it, so an author could:

- set `verified` (the admin-only checkmark, written by the plugin-store admin
  route behind plugins.admin.publish) with a direct UPDATE, or INSERT a new row
  already verified, since the INSERT policy never checks the column;
- change `org_id` on their own plugin to any organisation, including one they
  do not administer: the UPDATE policy's `auth.uid() = author_id OR
  is_org_admin(org_id)` short-circuits on the author clause.

The migration replaces each client role's table-level grant with column-level
grants on every current column except `verified` (INSERT and UPDATE) and
`org_id` (UPDATE only; INSERT stays under the policy's can_publish_org_plugin
check), read from the catalog as 20260918000000 does, and fails closed if the
privilege survives through PUBLIC or an inherited role. The plugin-store edge
function uses the service-role key and is unaffected; nothing in this repo
writes the table as a client.

plugin_trust_columns_test.sql (13 assertions): on unpatched dev 7 fail, with
the verified UPDATE and INSERT and the foreign-org UPDATE all landing. Full
pgTAP suite: 50 files, 1151 tests, pass.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test(db): pin the exact plugins columns closed to clients, and say how the next one is closed (#1747)

The three non-blocking follow-ups fluck-boss left on #1744's merge.

- plugin_trust_columns_test.sql pins the closed set exactly: {verified, org_id} for UPDATE and
  {verified} for INSERT, for authenticated and anon. A column added to public.plugins is not
  client-writable until a migration grants it; now it fails the suite until someone decides
  whether clients may write it, instead of landing closed without anyone noticing. With a
  `featured` column added, the four new assertions fail (`have: {featured,org_id,verified}`).
- The migration header names the "Users with plugins.admin.publish can update any plugin" policy,
  so "the edge function is the one legitimate writer" can be checked against admin tools
  outside this repository.
- The header says the block cannot be copied to protect a third column: it acts only on a
  table-level grant, and none remains after it runs. With per-column grants, a later migration
  revokes the one column directly.

Comments only in the applied migration; its statements are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(spaces): resolve named-save ownership at press time (#1749)

* fix(dashboard): keep recent-file dismissals through startup loads (#1751)

* refactor(dashboard): generalize in-flight list load guard

* fix(dashboard): preserve file dismissals during startup

* test(dashboard): cover file dismissal load races

* fix(auth): redact passkey IDs and narrow route diagnostics (#1760)

* fix(logging): keep private local state out of decoder logs (#1762)

* fix(logging): redact local-state decode failures

* fix(workspaces): keep private layouts out of decode logs

* fix(plugins): replay window registration snapshots on restore (#1356)

* fix(plugins): replay window registration snapshots

* fix(plugin): prepare a registration before dropping its predecessor

register() removed the owner's live entry before running target.prepare;
both current prepares catch Throwable and the rest are identity, so nothing
throws today - but any future fallible prepare would strand the served
registration with nothing published. Preparing first keeps the old entry
serving on a throw, at no cost to the success path.

* test(plugins): preserve registration after snapshot failure

---------

Co-authored-by: Instinct Agent <agent@instinct.com>

* fix(editor): prevent stale navigation target replay (#1405)

* fix(editor): unify navigation target replay ownership

* test(editor): cover navigation delivery contract

* test(editor): isolate navigation IPC coverage by platform

* test(editor): cover runtime serialization and burst capacity

* fix(dashboard): require guarded list mutations (#1764)

* :arrow_up: deps:(deps): bump kotlin from 2.4.10 to 2.4.20 (#654)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin.jvm
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.multiplatform
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.plugin.compose
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.plugin.serialization
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin:kotlin-test-junit
  dependency-version: 2.4.20
  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>

* fix: complete glass settings search after dev integration

* fix: handle recovery write failures and reject ZIP directory payloads

* fix(plugin-store): let organisation members download their organisation's plugins (#1596)

* fix(plugin-store): let organisation members download their organisation's plugins

Both download routes looked the plugin up with get_plugin_with_stats, which
resolves visibility for auth.uid(). The edge function holds a service-role
client, so auth.uid() is NULL and every `org` or `unlisted` plugin came back
as no row. The route answered "Plugin not found" before canInstall() saw the
caller, so no member could install or update their organisation's plugins.

The download lookup now reads the published plugin row directly and applies
no visibility; user_can_install_plugin stays the only gate, as the routes
already intend. Passing the viewer to get_plugin_with_stats_for_viewer would
fix `org` but not `unlisted`, because viewing is deliberately narrower than
installing. A refused caller still gets the same 404 as a missing plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(plugin-store): cover API key download visibility

* refactor(dialogs): split clone progress content

* fix(plugin-store): mark download responses private; close review test gaps

- Cache-Control: private, no-store on both download 200s: the body can
  describe an org plugin and carries a signed URL on a caller-less path.
- Correct the getPluginForDownload KDoc: published = true is the only
  publication guard, since the install predicate admits authors/admins.
- Tests: per-lookup filter recording, version-sensitive version stub
  (unknown version 404s), required-permission 403, lookup error 500
  without echoing the DB message, Cache-Control assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

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

* Stop a sleeping laptop from emptying the window overnight (v2, supersedes #1207) (#1771)

* Stop a sleeping laptop from emptying the window overnight

Two defects, in series, that between them lose a night's tabs.

PluginWatchdog judged heartbeat age on the raw wall clock. Stall
suppression is deliberately bounded (maxSkippedChecks), and a laptop
sleeping in quarter-hour bursts reaches that bound; the tick past it
then failed every plugin on an age that was almost entirely the host's
sleep, spending restart budget until a plugin was disabled and its open
tabs went with it. The watchdog now banks the suspension it measures and
subtracts it from heartbeat age.

The credit is cleared by a FULLY AWAKE tick carrying a beat. An anchor
on "newer than the tick start" is unsound: the last beat before a lid
closes lands inside the same tick that spans the suspend, so that test
discards the credit for the very sleep being measured. Only suspension
is credited, never frozen-while-awake overrun.

The credit is also spent by AWAKE time, not held until a beat. Without
that bound a plugin that dies during a sleep - worst case one that beats
once on the resume, so its sleep is counted both in the heartbeat age
and in the credit - buys itself the whole night before anyone asks about
it again. Continuous awake time with no beat now clears the credit once
it passes the unhealthy threshold, so detection is bounded by how long
the host has actually been RUNNING, whatever the wall clock did. The
same bound is what keeps a wall clock stepped forward by NTP, which is
indistinguishable from a suspend by the two-clock test, from delaying a
wedged plugin's restart by more than that.

Second, the layout watcher wrote whatever was on screen, so the moment a
tab-owning plugin was unloaded it put "tabs": [] over a full session -
the write that turned a recoverable window into a lost one.
layoutWatcherWrite now returns a null record for a layout with no tabs,
so the type forces the call site rather than leaving the condition to be
re-implemented there. The in-memory current workspace still tracks the
live layout, because a plugin's Save reads it.

Residual window, accepted: between a deliberate emptying of the window
and the next dispose or clean exit, a SIGKILL or JVM crash now restores
the last non-empty record instead of the empty one. Restoring tabs the
user had closed is a far cheaper failure than silently discarding tabs
they had not.

Also: skipping the write leaves reportUnsaved comparing against the
stale disk copy, so an emptied window reads as unsaved until dispose
writes it. Left alone - Last Session is not a document the user saves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Keep the watchdog's awake bound across sleeps

Review follow-up from #1207.

The awake counter that spends suspension credit was reset by every
suspended tick. A dead plugin on a laptop that wakes for less than the
unhealthy threshold never reached the bound, and in the double-counted
case (a beat on the resume) kept a whole sleep of negative age for
roughly that long in awake time. The counter is now cumulative, reset
by a beat alone.

Each tick adds at most one interval to it, so a freeze right after a
resume beat cannot spend the sleep's credit in one tick and restart a
plugin that is alive.

Also: drop an assertion in LayoutWatcherWriteTest that could never fail,
cover a tab nested two splits deep, make hasAnyTab private, and document
the claim-once and hard-crash residuals on the empty-layout guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(logging): pin the path-embedded protocol-relative leak and the maskUriParams side of #1663 (#1770)

Document the accepted residual path-embedded userinfo leak and pin its expected output in a regression test. Add maskUriParams coverage for the protocol-relative redaction from #1663. This does not fix the residual leak; its tracker remains open.

Refs #1665

* fix(terminal): closeStdin cancellation check, clarify IPC 1.4.0 load gate, tighten tests (#1777)

* fix(terminal): re-check coroutine activity after acquiring input lock in closeStdin

Issue #1677 item 1:
- Add currentCoroutineContext().ensureActive() right after acquireInputLock
  in closeStdin().
- If the caller was cancelled while parked on the input lock behind a slow
  writer, abandoning the call does not proceed to deliver EOF to stdin once
  the lock becomes available.

* docs(ipc): clarify IPC 1.4.0 minIpcVersion load gate semantics

Issue #1677 item 2:
- Clarify that an older host answers CloseInput with UNIMPLEMENTED only if
  the runtime declared minIpcVersion <= host.
- A runtime declaring minIpcVersion: 1.4.0 is refused at spawn time by
  <= 1.3.0 hosts.
- Drop misleading 'Like 1.0.0 below' comparison.

* test(ipc): split CURRENT version pin into dedicated test

Issue #1677 item 3:
- Split CURRENT == 1.4.0 pin out of the minIpcVersion compatibility test
  into a dedicated test current IPC version is 1_4_0.
- Keeps the version pin test cleanly distinct from the compatibility logic test.

* test(terminal): wrap join in withTimeout and add closeStdin cancellation test

Issue #1677 items 1 & 4:
- In TerminalCloseInputTest, wrap unbounded writer.join() in withTimeout(5_000)
  inside the busy test finally block so a hung writer cannot stall CI indefinitely.
- Add comment documenting that 'closing input twice is a no-op' pins the RPC-level
  idempotence contract.
- Add unit test 'cancelling a queued closeStdin abandons closing input' in
  TerminalSendQueueTest asserting that cancelling a coroutine queued on the input lock
  does not close stdin once the lock is acquired.

* refactor(auth): rename createLockFileExclusively and retain atomicWriteText for session key (#1675) (#1773)

Clarify lock sidecar creation by renaming createKeyFileExclusively to createLockFileExclusively and its parameter to lockFile. Retain atomicWriteText for session key publication under the same persistent sidecar lock. Add a desktop regression test for the resulting key and lock-file owner permissions.

The earlier CREATE_NEW final-key write in this branch was reverted before merge; it is not part of the resulting change.

* fix(terminal): release input mutex when acquire is cancelled (#1778) (#1779)

- Catch CancellationException in acquireInputLock and unlock ticket if held before rethrowing
- Update closeStdin KDoc to document post-admission ensureActive semantics
- Guard writer.join() with withTimeoutOrNull in TerminalCloseInputTest
- Add regression test for uncontended acquire cancellation in TerminalSendQueueTest

* fix(windows): preserve localhost authority for genuine UNC share paths (#1645) (#1772)

* fix(windows): preserve localhost authority for genuine UNC share paths (#1645)

* Refactor CodeSourceLocation to chain recoverCatching and tighten tests

* fix(plugin-store): gate direct version reads on finalized rows (#1630) (#1775)

Gate direct plugin_versions reads on finalized rows while retaining the existing plugin-visibility policy. Add pgTAP coverage for public, org, and unlisted versions across anon, outsider, member, author, admin, and service-role access, including pending and half-finalized rows. Handle finalized version UPDATE events in PluginStoreRealtimeService and test the client handler.

Deployment checks remain: audit existing real-sha versions with zero or NULL jar_size before applying this migration, and verify finalized UPDATE delivery through a live realtime client. The UI refresh callback and authenticated org realtime socket are separate follow-ups, not verified by these unit tests.

* fix(plugin-store): refuse over-cap JAR reads on declared size, cover the inflate cancel branch (#1783)

Add pre-read declared-size refusals for remote JAR ranges and EOCD probes, cancel refused response bodies, and catch writable-side decompression rejections.

Tests exercise actual remote inflate-limit cancellation, finite over-cap EOCD/CD refusal streams, and release-asset refusal with zero reads and an observed cancel callback.

Reviewed at 51d72cc against dev 1d381e6 with clean author-only changes, completed exact-head second-layer review, and 18 successful checks including Plugin Store, pgTAP and build-test. Broader remaining bounds work is separate; this change does not claim to close every memory-exhaustion path.

* fix(mcp): harden secret references against casing, plaintext leaks, and drift (#1672) (#1784)

* fix(mcp): harden secret references against casing, plaintext leaks, and drift (#1672)

- McpJsonDepth.kt: Enumerate all four guarded parses on invoke path in KDoc
- SecretReference.kt: Support case-insensitive {{secret:...}} prefix matching
- McpArgumentSanitizer.kt: Expand VALUE regex to avoid premature stop at '}', and redact malformed {{secret:...}} candidates
- McpSecretPrePass.kt: Sanitize literal in malformed secret reference refusal
- McpToolRegistryImpl.kt: Log only exception class name in parseMcpToolArgs failure
- docs/MCP_SECRET_REFERENCES.md: Document case-insensitive prefix handling
- Tests: Add coverage for case-insensitivity, redaction of malformed candidates, and stack safety

* fix(mcp): resolve review blockers on secret reference hardening (#1672)

- Redact unterminated and nested malformed candidates in McpArgumentSanitizer
- Stop echoing raw agent-supplied field name in SecretReferenceParser refusal reason
- Sanitize both scan.literal and scan.reason in McpSecretPrePass
- Use decodeFailure(e) and handle SerializationException in nonObjectArgsError and parseMcpToolArgs
- Allow adjacent valid secret references in sensitiveAssignment
- Expand test coverage with unterminated shapes, adjacent references, and casing fuzzing
- Update KDoc and documentation in MCP_SECRET_REFERENCES.md

* fix(mcp): resolve review blockers on unterminated references and decode logging

- Make malformedSecretReference regex tolerant of unterminated candidates and bound at closing brace
- Allow repeated adjacent valid secret references in sensitiveAssignment lookahead with possessive group
- Report offset and length in SecretReferenceScan.Malformed and omit raw candidate text in refusal descriptor
- Stop echoing unverified fieldPart verbatim in SecretReferenceParser.parseBody
- Sanitize scan.reason in McpSecretPrePass.prepare
- Use decodeFailure(e) for SerializationException in nonObjectArgsError and parseMcpToolArgs
- Add unit tests for unterminated shapes, adjacent valid references, wireName alternation pinning, and small-stack safety
- Update KDocs and docs/MCP_SECRET_REFERENCES.md

* fix(cli-installer): bound Windows PATH update process timeout (#1254) (#1786)

* fix(cli-installer): bound Windows PATH update process timeout (#1254)

- Bound setx process.waitFor with 30s timeout and destroyForcibly on expiration
- Drain stdout and stderr asynchronously on daemon threads to prevent pipe buffer deadlock and capture failure diagnostics
- Return false and log warning with captured streams on timeout or non-zero exit code
- Extract executeSetx to maintain clean separation and obey linter complexity limits
- Add CLIInstallerWindowsPathTest unit and regression tests covering success, failure, timeout, and pre-existing PATH paths

* fix(cli-installer): destroy child on exception and discriminate async drain in tests (#1254)

- Clean up child process and drain threads in executeSetx finally block
- Restore Thread.currentThread().interrupt() on InterruptedException
- Require positive timeoutSeconds in updateWindowsPath and executeSetx
- Move drain thread joins above finished branch for reliable diagnostics
- Make top-level constants private
- Add mutation-discriminating test using FakeProcess stdout blocking until destroyForcibly
- Add interruption and argument validation tests

* fix(cli-installer): add test timeouts, safe interrupt clearing, and log before interrupt restore (#1254)

* fix(workspaces): redact decode failures on CLI workspace load (#1711) (#1788)

* fix(workspaces): redact decode failures on CLI workspace load (#1711)

- In BossAppEventBusEffects, handle SerializationException separately from
  general exceptions when deserializing Space JSON from WorkspaceEventBus.
- Use decodeFailure(e) to log structured offset and exception-type diagnostics
  without leaking the offending Space JSON (tab URLs, project paths, terminal commands)
  into host logs.
- Retain throwable on ordinary I/O exceptions in catch (e: Exception).
- Extract subscribeWorkspaceLoadEvents to enable window-filtered event testing.
- Add listener-level regression tests in WorkspaceDecodePrivacyTest covering:
  * Redaction of private layout data on corrupt Space CLI loads
  * Filtering of events destined for other windows
  * Successful dispatch and zero-warning logging for valid Space loads
  * Retention of throwables on ordinary I/O errors

* fix(workspaces): deterministic subscriber readiness, distinct spacePath key, and cancellation preservation (#1711)

- Expose WorkspaceEventBus.subscriptionCount to allow tests to synchronize
  subscriber registration deterministically before event emission.
- Use 'spacePath' key in log metadata to avoid key collision with decodeFailure's 'path'.
- Rethrow CancellationException in subscribeWorkspaceLoadEvents to preserve coroutine cancellation.
- Replace arbitrary delays in WorkspaceDecodePrivacyTest with CompletableDeferred signals,
  cancelAndJoin lifecycle, and deterministic readiness assertions.

* test(auth): observe and make visible Windows inconclusive path in UserDataStorageMergeTest (#1676) (#1791)

- Invert comment-only empty if branch into assertNonVacuousRace helper

- Log 'inconclusive on this leg' with observed form count when Windows file handle contention blocks atomic writes

- Add unit tests pinning platform non-vacuity behavior for both landed and unlanded writes

- Fixes #1676

* fix(wizard): align stageAndInstall in-place cleanup with refusal premise (#1648) (#1790)

When stageAndInstall runs in in-place mode (downloadedFile == finalFil…
shivanshu-risa added a commit that referenced this pull request Oct 7, 2026
…uance, ISO 4217 amounts (replaces #1769) (#1830)

* fix(settings): recover corrupt keymap, zoom and workspace settings files, and lock zoom updates (#938)

Co-authored-by: abhinavlenka <abhinavlenka@users.noreply.github.com>

* fix(plugins): follow a Downloads folder the user moved on Windows

Windows lets the user move Downloads (Explorer, Properties, Location), for
instance to D:\Downloads, but BOSS kept answering %USERPROFILE%\Downloads.

- WindowsDownloadsFolder reads the shell's value for FOLDERID_Downloads with
  reg.exe, parses REG_SZ and REG_EXPAND_SZ, expands %NAME% variables and keeps
  only an absolute path. It needs no native binding, so the host and plugin
  processes read it the same way. Read once per JVM; a missing value, a
  missing reg.exe or a run past 5 s fall back to the old answer. It is only
  consulted when user.home is the Windows profile, so test tasks that
  redirect user.home never read the registry.
- DownloadsDirectory.current() passes it as the known folder, which still
  wins only when it names a real directory.
- FileSystemDataProviderImpl.writeFile/readFile admit the Downloads folder the
  provider hands out, and paths under it, in addition to home. Compared on
  real paths, so .., symlinks and junctions cannot lead into a sibling; a
  Downloads folder at a filesystem root admits nothing; delete stays
  home-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): log an unread Downloads known folder once, and make two reader tests test what they say

Review follow-up for this PR.

- The desktop getDefaultDownloadsDirectory logs once per JVM when the Windows
  known folder could not be read, so a failed registry read is no longer
  indistinguishable from a folder that was never moved. plugin-path-utils stays
  logger-free.
- The commonMain expect KDoc no longer says Windows is always
  %USERPROFILE%\Downloads.
- WindowsDownloadsFolder.current checks os.name itself instead of relying on
  its only caller to.
- "a successful run returns its standard output" ran java -version, which
  prints to stderr, so it passed on empty output; it now runs --version and
  checks the version is in what came back (a planted stdout-dropping mutation
  fails it). The timeout test uses a program that sleeps 60 s against a 1 s
  timeout instead of a 0 ms race.
- RelocatedDownloadsAccessTest's outside-home premise is a @BeforeTest
  assumption, so it gates every case instead of only itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(build): ship plugin-path-utils with the upstream jars plugin-api-ipc needs

plugin-api-ipc calls DownloadsDirectory from plugin-path-utils at runtime
(FileSystemDataProviderProxy), but assembleUpstreamJars shipped only four jars,
none of which contains it. The standalone microkernel runtime would compile
against plugin-api-ipc and then fail with NoClassDefFoundError on the first
getDownloadsDirectory() call.

- assembleUpstreamJars now also ships plugin-path-utils-<ver>.jar, with the
  same dependsOn and rename trick as the other plugin-platform jars, and the
  floor on produced jars is 5, so a missing one fails the task.
- plugin-path-utils gets version 1.0.0, like plugin-api-ipc and
  plugin-api-core, so its jar has a versioned name the runtime can pin.
- The task KDoc and the release.yml comment list the fifth jar.

Before: 4 jars, none containing ai/rever/boss/plugin/pathutils. After: 5 jars,
plugin-path-utils-1.0.0.jar contains DownloadsDirectory.class and
WindowsDownloadsFolder.class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): refuse a Downloads path that cannot be resolved, and make the timeout test time itself

Review follow-up for this PR.

- isInsideDownloads fails closed: an IOException from canonicalFile or
  toRealPath now makes the check return false, so the plugin gets the
  SecurityException refusal instead of a generic I/O failure.
- realPath climbs with Files.exists(NOFOLLOW_LINKS), so a dangling symlink or
  junction inside Downloads is found as the link it is; toRealPath on it fails,
  and the write is refused instead of being checked as a plain name and then
  followed. The KDoc says so.
- The timeout test asserts the elapsed wall time is at least the timeout, so a
  child that fails at once (which also gives null) cannot pass it.
- A comment on the home branch of isReadableAndWritable says it compares
  canonicalFile paths and is knowingly weaker than the Downloads branch.
  Behaviour unchanged.

Seen red first: on the previous code both new RelocatedDownloadsAccessTest
cases fail (IOException: Invalid file path; FileNotFoundException through the
dangling junction), and the timeout test with a child that fails at once
fails after 124 ms, where the old version of the test passed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(auth): pin current passkey attempt and auth arguments (#1691)

* test(auth): pin current passkey attempt and arguments

* test(auth): preserve superseded attempt loading assertion

* fix(performance): clamp the plugin heap before bounding the initial heap with it (#1696)

PerformanceSettings.validated() bounded pluginJvmInitialHeapMb by the stored
pluginJvmHeapMb, not the clamped one. A stored max below 32 made the range
empty, coerceIn threw, and PerformanceSettingsManager's load fell back to
defaults, dropping every performance setting in the file over one bad value.
A stored max above 8192 let the initial heap end up above the clamped max.

Clamp the max heap first and bound the initial heap by that value.

Fixes #1000

* test(db): pin the viewer wrapper's page-size floor, and say LIMIT 0 changed (#1671 follow-up) (#1700)

#1671's re-review found the one clamp term its test left unpinned: GREATEST(..., 1) on
search_plugins_for_viewer's page size. Without it, the null and 101 cases still give 20
and 100, so a viewer-only typo there would ship. One assertion at 0 pins it, mirroring the
search_plugins block.

The page-size-0 case also gets a comment saying the behaviour changed. LIMIT 0 is legal, so
a direct call used to return an empty page with a correct total_count; it now returns one
row, matching get_popular_tags. The note lives in the test rather than in the merged
migration, which is already applied.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test: fail on skipped tests in every module and in the IDE, not only composeApp's Gradle run (#1699)

Review follow-up on #1667, which merged before these landed.

- The discovery-issue guard moves from composeApp's Test tasks to the root allprojects
  block, so every module's Gradle test run fails on a @Test JUnit will not execute.
  Checked by adding a value-returning test to plugin-loader, whose run then failed with
  "must not return a value".
- composeApp repeats it in src/desktopTest/resources/junit-platform.properties, which
  JUnit reads however it is launched, so an IDE run meets it too. Checked with the root
  property removed: the same probe in composeApp still failed the run.
- The build comment says what a failure looks like (discovery aborts for the whole engine,
  and one initializationError names the method) and to relax the property to ERROR rather
  than delete it if a toolchain bump brings an unrelated warning.
- AGENTS.md records the rule beside the composeApp test-home note: declare an
  expression-bodied test `(): Unit =`, or write runBlocking<Unit>.
- McpApprovalGateTest's buffer test waits on pendingList instead of a 50 ms sleep, as its
  deny-all sibling already does. The unknown-surface test now observes the no-op: nothing
  appears, and a real surface survives.

All 29 modules' test tasks with the guard on: 8,414 tests, 0 failed. ktlint and detekt
clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugin-store): the #1508 tidy-up - one ceiling per bound, a real 400 body, paged routes bounded (#1669) (#1702)

All seven items from #1669, the follow-ups to #1508.

1. The popular-tags handler's Number.isInteger guard was dead: the route's schema validates
   before the handler runs, so `limit` is already an integer in 1..100. It is deleted, the
   handler reads `const { limit } = ctx.req.valid("query")`, and the NaN / LIMIT NULL
   rationale moves onto the schema. The handler's comment claimed an invalid limit still
   spent rate-limit budget, and /search's claimed the limit was consumed before parsing;
   both were untrue for the same reason and now say what happens.
2. browse gets a defaultHook, so a request that fails its schema is answered with the
   ErrorResponseSchema { error } every route declares, naming the field, rather than the
   validator's own { success: false, error: <ZodError> }.
3. Each ceiling is defined once in types/schemas.ts: POPULAR_TAGS_LIMIT_MAX,
   CATALOGUE_PAGE_SIZE_MAX, CATALOGUE_PAGE_MAX. The popular-tags query schema moves there as
   PopularTagsQuerySchema.
4. schemas.test.ts imports that schema instead of restating it, so moving the route's cap
   can no longer leave its tests green. It also pins the cap's edge.
5. /list declares its 400. catalogue-paging-bounds.test.ts checks /list?pageSize=999999999
   gets that 400 before the database.
6. Both routes bound `page` (1..10,000) and /search gains .int() on page and pageSize.
   A deeper page is a 400 instead of an empty page from the SQL clamp.
7. The popular-tags recorder keeps the RPC name and asserts get_popular_tags.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test(settings): fail on any settings or state writer that falls back to writeText (#1701)

A regression watch for #1022, which routed thirteen writers through atomicWriteText
(#1011, #1659) with a test for only one of them. Asked for when #1661 was closed as
superseded.

SettingsAtomicReplaceTest has one case per writer. Each saves through the API the app
uses and asserts the file got a new identity: a file rewritten in place keeps its inode,
and one replaced by rename gets a new one.

- The old inode is pinned with a hard link before the measured save, because ext4 hands a
  just-freed number to the next file created and a correct replace could compare equal.
  That happened once on ubuntu in #1661.
- The file is created first when absent, so a writer that loads on a background thread
  reads it rather than writing a default that would hide a reverted save.
- Installed plugins adds its own entry and removes it, leaving the shared installed.json
  as it found it. The HTML and plugin-state stores run on temp files.
- Windows exposes no fileKey, so the cases are skipped there with the reason.

Mutation-checked against a fresh test home: switching each of the thirteen files from
atomicWriteText to writeText fails exactly that file's case, 13 of 13. 1,181 tests across
the settings, plugin, html and filetypes suites pass; ktlint and detekt clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): make the corrupt-file aside helper non-throwing, and pin its collision path (#1704)

Follow-ups from the #938 merge review.

renameAsideCorrupt promised best-effort failure through its return value,
but toPath() throws InvalidPathException for a name the filesystem cannot
represent, and the move and the permission narrowing can throw
SecurityException. The three settings managers call it from recovery paths
their object initializers reach, so an escape failed the whole manager.
Both are now caught and logged, and the call returns false (#1692).

The stamp can be supplied, so the collision test pre-creates the first
aside and checks the retry to -1 keeps both copies, instead of relying on
two moves landing in one millisecond. A second test fills every suffix and
checks that nothing is overwritten (#1693).

The keymap Json comment now says that coercion is not a round trip: the
older build's next write persists the default, so a downgrade still loses
that one field, and a test pins it (#1694).

Fixes #1692
Fixes #1693
Fixes #1694

* fix(plugin-store): every router answers a schema failure with the 400 body it declares (#1705)

#1702 gave `browse` a defaultHook, so a request that fails its schema gets the
ErrorResponseSchema { error } its routes declare. The other five routers (publish,
api-keys, rating, download, admin) still answered with the validator's own
{ success: false, error: <ZodError> }. That body isn't the declared shape, and it hands the
caller the schema's internals. The #1702 review found this gap.

- utils/router.ts: newRouter() builds every plugin-store router with the hook and its
  invalidRequestMessage helper, moved out of browse.ts. It is a factory, not an app-level
  hook, because the hook is bound into a route's validators when router.openapi()
  registers it: a hook on the app in index.ts would never reach a sub-router's routes.
- All six routers use it.
- tests/router-hook.test.ts provokes one schema failure per router that has one (browse,
  rating, admin, api-keys, publish), using a client whose database and auth calls throw.
  Each must return 400 with only an `error` key, naming the field. download's routes take
  only path strings, which nothing fails, so a source scan covers it: no route file may
  construct OpenAPIHono directly, and the six routers must all be built by newRouter.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test: the #1699 and #1701 review follow-ups - buildSrc's guard, clearer settings-test failures (#1708)

Both PRs merged before their non-blocking review points could land.

#1699:
- buildSrc sets junit.platform.discovery.issue.severity.critical=WARNING itself. It is a
  separate build the root allprojects block never reaches, and its tests run in CI's own
  `./gradlew -p buildSrc test` step. All four buildSrc test classes pass with it on.
- The root comment and AGENTS.md say an override belongs in the module's own
  build.gradle.kts, which wins, and that buildSrc carries its own copy.

#1701:
- An unchanged identity is now reported as one of two things, told apart by the file's
  modified time: rewritten in place, or not written at all. The writers log a failed save
  and swallow it, so the old single message sent a reader after a writeText that wasn't
  there. Checked by forcing both.
- The class KDoc records that each measured save is assumed to write unconditionally.
- The default-apps case restores the test-home file and resets the manager, since it is
  the one case that changes shared state.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* build(buildSrc): pin JUnit to the root's version so the discovery-issue guard is live (#1709)

#1708 added junit.platform.discovery.issue.severity.critical=WARNING to buildSrc, and its
review asked whether that line was actually read. It wasn't. buildSrc's only test dependency
was kotlin("test"), which resolves JUnit 5.10.1 / Platform 1.10.1 through Gradle's embedded
Kotlin. The property arrived in Platform 1.13, so nothing read it: a value-returning @Test
in buildSrc was still skipped with the build green.

- buildSrc/settings.gradle.kts imports the root version catalog.
- buildSrc/build.gradle.kts adds the JUnit BOM at libs.versions.junit.jupiter (6.1.3, the
  root's) and the platform launcher. Every JUnit artifact resolves to 6.1.3, and a later bump
  of the root moves buildSrc with it.

Checked both ways with a throwaway `fun probe() = 42` test in buildSrc. On dev it passed
silently (exit 0); with this change it fails with "must not return a value". buildSrc's own
43 tests pass, the root build configures, and ktlint and detekt are clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): record escalated shell calls in the ledger, and match format only as a command (#1655) (#1698)

* fix(mcp): record escalated shell calls in the ledger, and match format only as a command

Two of the #1655 leftovers the Linux seat testing still hits.

The ledger row of a CRITICAL shell call that a saved ALLOW did not cover
now carries escalated = true. Under YOLO mode such a call is answered
YOLO_ALLOWED with policyApplied = ASK, exactly like a routine one, so an
audit could not tell a destructive call that ran unattended from any
other. The field is hashed only when set, like secretRefs, so every
existing record keeps its hash; boss mcp ledger tail/search show it.

The registry states the escalation instead of inferring it from
policy != savedPolicy, so the prompt and the ledger row read one answer.

The evaluator matched the text "format " anywhere, which rated
--format json, clang-format and English typed through send_input
CRITICAL and made a saved Always Allow ask again for all of them. format
now counts only as the command word of a segment, or when followed by a
drive such as d:.

Refs #1655

* fix(mcp): address the #1698 review - format with leading switches, and the escalated flag's scope

- format with its switches before the volume (cmd /c format /q e:) rated
  HIGH, because only the token right after format was checked for a
  drive. The whole rest of the segment is scanned now; none of the false
  positives the change removed carries a drive letter.
- The escalated flag means the destructive-shell gate overrode a saved
  ALLOW, not that a call was destructive. Under the default ASK policy a
  destructive call YOLO answers records false. The record KDoc and
  AGENTS.md now say so, and a test pins that case. The KDoc also names
  the secret-policy case and the downgrade caveat for ledger verify.
- authorizeInvocation's escalated parameter loses its default, so a new
  caller has to answer.

* docs(agents): say the discovery-issue guard needs JUnit Platform 1.13, and why buildSrc pins the BOM (#1710)

Requested on #1709. junit.platform.discovery.issue.severity.critical is silently ignored on
JUnit Platform older than 1.13, and a passing run looks the same either way. buildSrc's BOM
pin (#1709) is what makes its guard live, so AGENTS.md now says so, and says how to check
a module with a throwaway value-returning test. That keeps a later "redundant dependency"
cleanup from quietly making the guard inert again.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(logging): log a failed decode by type and path, never with the file's content (#1629, #1695) (#1703)

* fix(logging): log a failed decode by type and path, never with the file's content

kotlinx puts the document it failed on into the exception message, and
even the diagnostic before it can quote a value. Logging those exceptions
as error = e wrote visited URLs with their query strings (recent pages,
#1629), the domains a user zoomed, and keymaps into the log that people
attach to bug reports (#1695).

decodeFailure(e) returns log fields with only the exception type, the
offset and the JSON path, with map keys masked since the zoom settings key
by domain. The recent-pages load and history bootstrap, the three
corrupt-file paths from #938 and the keymap import now log it instead of
the exception. SerializationException is caught ahead of the general
catch, which it would otherwise fall into as an IllegalArgumentException.

Fixes #1629
Refs #1695

* fix(logging): address the #1703 review - read markers from the diagnostic only, never throw

- The offset and path are read from the diagnostic, never from the
  document kotlinx appends after "JSON input:". The offset must lead the
  message, before anything kotlinx quotes, and an out-of-range number is
  dropped with toIntOrNull: a throw here ran inside the caller's catch
  and skipped its recovery.
- The path comes from the LAST marker on the first line, since kotlinx
  appends the genuine one after any value it quotes, and after masking it
  must be pure structure or it is left out. A marker spelled by a value
  or hidden in a key is not logged.
- Map keys are masked by index from the first [' to the last '], so a
  key holding '] or a Unicode line separator cannot end the mask early.
- The rest of the #1629 data and its neighbours: UrlHistoryManager (the
  browser-history.json that recent pages bootstraps from), ResolvedHostsStore,
  RecentFilesManager and SelfHealingSettingsManager's settings load now
  log decodeFailure(e) too.

* fix(logging): stop user_data.json decode failures logging the user's email and id

Review on #1703: UserDataStorage's two corrupt-record arms logged the
decoder's exception, whose message quotes the record - the user's email
and id. Both now log decodeFailure(e), and a test drives a torn record
through both paths and checks no entry carries the exception, the email
or the id.

Also from the review:
- A quoted value with a newline moved the genuine path off the first
  line, leaving the value's own marker last. An odd number of quotes
  before the marker means the line ends inside a value, so the path is
  left out.
- The offset scan is bounded to the start of the diagnostic.
- The KDoc says a non-identifier serial name drops the path too, and the
  space-in-key test comment describes the current masking.

* fix(logging): cover the last two user_data.json decodes

Review on #1703: loadUserData (the startup path, at ERROR) logged the
decoder's exception, and readStoredWizardFlag logged its toString(),
which is the same message by another route. Both quote the record, the
user's email and id. Both now log decodeFailure(e).

The torn-record test now drives all four decodes - loadUserData,
saveUserData's stored-flag read (at DEBUG), and the two wizard paths -
and checks each logs once with no exception, and no entry carries the
email or id.

Also: the odd-quote KDoc says it is a heuristic a value's own
apostrophes can defeat, bounded by the structural check; and the
AGENTS.md bullet forbids the decoder's message by any route and points
at #1711 for the older sites.

* fix(mcp): preserve legacy provider DENY rules (#1682)

* fix(mcp): keep a long argument from crashing the audit record of a call that already ran (#1712)

* fix(mcp): keep a long argument from crashing the audit record of a call that already ran

McpArgumentSanitizer.sanitizeMessage builds the ledger record inside invoke's
finally, after executeAuthorized has run, and the approval request before a
prompt. Its PEM rule repeated three GROUPS greedily - `(?:\s|\\n)*`, the
header-line body and the header lines themselves - and java.util.regex recurses
once per iteration of a greedy group repeat. `-----BEGIN PRIVATE KEY-----`
followed by 4,000 spaces overflows a default thread stack. The schema gate
ignores keys no schema declares, so a call under any ALLOW (saved rule, trusted
plugin, session trust, YOLO) could carry that payload next to its real command:
the tool ran, the ledger lost the row, no dropped-write was counted, and invoke
threw a StackOverflowError to its caller. With a prompt, the approval path
overflowed first and the call died unlogged. sensitiveAssignment's name suffix
`(?:[_-][A-Za-z0-9]+)*` overflows the same way on `password_a_a_a...`.

Every repeated group in the rules is now possessive (`*+`), which the engine
runs as a loop. Nothing that may follow any of those groups can be a character
the group consumed, so no match needs one of them to give anything back, and
the redactions are unchanged: the fuzz, auth-scheme, credential-shape, URI and
secret-reference suites pass as before.

sanitizeMessage also catches StackOverflowError and records
`[OMITTED: could not be sanitized]`: a backstop for a rule added later without
that care, which withholds the value rather than showing it raw.

McpArgumentSanitizerStackSafetyTest states the property: every rule's trigger
(25) followed by every pathological filler (18), at the ledger's 16,384-character
cap and at four times it, sanitizes on a 256 KB stack without throwing and
without reaching the backstop. Against dev it fails 62 cells, all
StackOverflowError, and both registry-level tests (a call run under ALLOW keeps
its ledger row; a prompted call reaches the operator and the ledger) fail with
the StackOverflowError escaping invoke.

AGENTS.md records the rule for anyone adding a sanitizer pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: leave AGENTS.md to the maintainers

The rule this change relies on is stated where it lives, in the KDoc beside
the code; AGENTS.md policy text is not merged from contributor PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kernel): check the requested action against the registered manifest before dispatch (#1716)

invokeCapability checked only that the target process was registered,
never that it advertised the requested action, while listCapabilities
advertised from the same registeredProcesses table - so the set the
kernel dispatched was strictly wider than the set it advertised. Check
the requested action against the registered process's own manifest at
the invokeCapability seam, before the broker is consulted:

- read registeredProcesses[pluginId] once;
- refuse "Process not found: <id>" when absent;
- refuse "Plugin <id> does not advertise capability: <action>" when the
  manifest has no matching action;
- only then hand the request to the broker. Advertise and dispatch now
  agree by construction.

Refusals go through one refuseCapabilityInvocation helper: the kernel
log copy is neutralized (IpcLogText.neutralize) because the reason
carries caller-supplied ids, while the response keeps the raw reason
for the authenticated caller. The verdict itself lives in a
capabilityAdmissionRefusal helper, which keeps the single registry
read and invokeCapability inside detekt's ReturnCount limit.

Four files:
- KernelServiceImpl.kt: the admission check and the refusal helper.
- KernelCapabilityAdmissionTest.kt (new): 5 refusal tests ported from
  #1363 to the new seam - unadvertised action, sibling-namespace
  action, respawn-with-fewer-capabilities, evicted-after-failure
  process, granted call dispatched with exactly the admitted pair -
  over the real authenticated transport, with a recording broker that
  is asserted never-consulted on refusals.
- KernelCapabilityRpcTest.kt: the unwired-broker test now registers
  capability("echo") so it pins the wiring refusal one step past
  admission.
- KernelLogForgingTest.kt: a refused invocation's log line cannot
  forge kernel log records.

Supersedes #1363, whose ProcessRegistryCapabilityResolver seam #1153
retired; the portable refusal tests are ported with credit preserved
per fluck-boss's 2026-09-25 13:23 UTC note.

* feat(browser): await quiescent browser calls (#1063)

Host-side BrowserHandle.awaitBrowserCallsQuiescent implementation and tests. Plugin-facing SDK release and boss-plugin-api pin bump tracked in #1726. Co-authored contribution remains with sidharth-vijayan through PR #1063.

* fix(db): the plugin store's name sort sorts by name, and its pages are stable (#1715)

* fix(db): the plugin store's name sort sorts by name, and its pages are stable

search_plugins_internal ordered by one sort_key, and that key had three defects, all
visible to a store client:

1. p_sort_by = 'name' sorted nothing: its key was the constant 0, so every row tied. The
   host sends 'name' for PluginSortOrder.NAME, and the local repository really sorts by
   displayName, so the same choice meant different things by source.
2. Ties were unordered for every sort, and LIMIT/OFFSET over an unstable order lets two
   pages of one listing repeat a plugin or skip one.
3. Every row's JSON was built before the offset: its version, rating, count, tag and
   organisation lookups ran for each row, then all but one page were thrown away.

The function is restated from 20260923133000 with only the ordering and the order of work
changed. Rows are ranked in a CTE by (lower(display_name) for 'name', sort_key DESC,
plugin_id), which is a total order because plugin_id is unique. Only the page is joined
back and projected. The filters, JSON keys, total_count, signature and ACL are unchanged.

search_plugins_order_test.sql uses five plugins whose plugin_id order differs from their
name order. It pins the name order, including on a second page, and that three pages over
five tied rows see each row exactly once. It also pins the unchanged count, the exact JSON
key set, the last and past-the-end pages, the ACL, and the name sort through the anon
wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(db): replace an assertion that could not fail; cover the filters on both copies

Review follow-up on #1715.

- The "same request twice" assertion compared one deterministic query with itself, so it
  would have passed against the pre-fix function too. It now asserts what it reached for:
  page 1 at size 2 is the first two rows of page 1 at size 5, which an unordered tie cannot
  promise.
- The filter block is written twice, once for total_count and once for the page, and no
  test in supabase/tests passed p_tags or p_verified_only. New assertions check a tag
  filter and verified-only on both the page and total_count, so a copy that drops a filter
  from one of them now fails.
- The migration header warns against folding the count into `ranked` as
  count(*) OVER (): on a page past the end `ranked` has no rows, so total_count would read
  0 instead of the number of matches.

plan(15) matches the fifteen assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ipc): build created and renamed paths in the proxy the way the host does (#1553)

FileSystemDataProviderProxy joined paths by hand ("$parent/$name") in
createFile, createFolder and rename, and returned that string to the plugin.
The in-process provider returns File(parent, name).absolutePath, so on Windows
the two disagreed on every call (mixed separators), and on every OS a parent
passed with a trailing separator produced a doubled one. rename of a path with
no parent also sent a bare name to the kernel, where the in-process provider
refuses with "Cannot determine parent directory".

FileSystemCreatedPathsTest drives the real proxy against a fake
FileSystemService: 5 cases, all failing on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cli): boss mcp ledger secrets - which tools received a credential, and when (#1733)

* feat(cli): boss mcp ledger secrets - which tools received a credential, and when

The ledger has recorded `secretRefs` on every governed call since #822, and
`boss mcp ledger tail|search` printed them per record, but nothing could answer
the question an operator asks before rotating a credential after an incident:
which tools and plugins received this secret, how often, and when last.

- `boss mcp ledger secrets` prints one summary per secret the matching calls
  referenced, most recently used first: calls that named it, how many handed it
  to a tool's handler and how many were withheld, the fields named, the tools
  (with counts) and plugins that asked, and the first, last and last-delivered
  times. `--json` gives the same as a machine-readable document.
- `--secret <id>` (any field of that secret) or `--secret <id>.<field>` (that
  field only) and `--provider <id>` filter `tail`, `search` and `secrets`. A
  selector that is not a secret id, or names a field a reference cannot, is
  refused rather than silently matching nothing; ids are matched case-folded, as
  the parser records them.
- "Delivered" is an exhaustive `when` over McpApprovalDisposition
  (`reachedHandler`), so a disposition added later is a compile error here
  rather than silently counted either way. Every approval disposition means the
  handler ran, because an approval withdrawn at the revocation or secret fence
  is recorded as POLICY_DENIED or SECRET_FORBIDDEN instead; CANCELLED_IN_FLIGHT
  had already started its handler, so it counts as delivered.

It reads only what the ledger records - references, never values - so it needs
no vault access and works with BOSS closed, like the other ledger actions. The
summary lives in its own McpLedgerSecrets object so McpLedgerCli keeps the size
its other actions need.

Tests: six in McpLedgerCliTest (delivered and withheld counted apart, one call
naming two fields counted once, a field selector, search by secret, field and
provider, the human report, selector validation, and "no secret" as an answer)
and two through Clikt in BossMcpCliTest (--file/--secret/--json end to end, and
a bad --secret failing with exit 1). docs/CLI.md documents the action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): let ledger --provider match a plugin, and say what the secrets report covers

Plugin-contributed tools record their provider as `<pluginId>::<providerId>`, so
`--provider secret-manager` matched nothing and an audit read "this plugin never received the
credential". A plugin id now matches every provider the plugin registered, and the scoped form
still matches one; the help text and docs say both.

- The human report labels provider ids `providers:` (the JSON key), prints which ledger it read,
  says a use older than the oldest rotated backup is not counted, and points at
  `boss mcp ledger verify`, on an empty report as well as a full one.
- Legacy CANCELLED, which predates the in-flight split, counts as a possible delivery: no record
  carrying a reference should have it, and for a credential audit over-reporting is the safe way
  to be wrong. The KDoc gives the dates that make it unreachable.
- A comment on the dotless-reference default, and the shadowing parameter renamed.

With the fixes reverted, 4 of 24 McpLedgerCliTest tests fail, including
`expected [vault_list, vault_get] but was []` for the plugin id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(logging): redact local decode failures (#1727)

* fix(chromium): cap archive extraction against a zip bomb (#1546)

* cap Chromium archive extraction against a zip bomb

Extraction now refuses an archive with too many entries or too many bytes, counted from what is actually read and named in the entries and never from ZipEntry.size. Directory entries and long paths draw from the same byte budget as file content, so they cannot create directories past it. The limits are parameters of extractWithJava with constant defaults. The macOS ditto path is unchanged.

* count backslash as a path separator in the extraction budget

Extraction runs on Windows, where a backslash in an entry name separates path components. Counting only the forward slash charged a deep backslash path as one component while it created one directory per segment. Both separators are counted now, with tests for the charge and for a deep backslash archive being refused before anything is created. Also corrects the limits comment: entries cost inodes, not file handles.

* Add owner-scoped RPCs for terminal session sharing (#1731)

* feat: add authenticated RPCs for terminal session sharing

* test: define terminal session RPC replacement contract

* feat: add terminal broadcast relay and account settings (#1734)

* feat: add terminal broadcast relay and account settings backend

* fix: harden relay admission accounting and review validation

* Fix relay queue boundaries and restore synchronization before production

* feat: restrict production relay credentials to ticket admission

* fix: return HTTP conflict for stale terminal preference revisions

* test: verify deployed terminal settings and relay admission

* test(git): make shell glob controls deterministic (#1737)

Co-authored-by: Gavin-Yau <2695188238@qq.com>

* fix(workspaces): stop an earlier session's Space set overruling the crash-recovery record (#1713)

* fix(workspaces): keep the Space set as fresh as the crash-recovery record, from one writer

Last_Session_Set.json was written only at a clean shutdown, and restore reads
it in preference to Last_Session.json. So after a session that restored a set,
worked and was then killed, the next launch brought back the clean shutdown
BEFORE it, and the watcher then wrote those older layouts over the only fresh
copy. Separately, every window's layout watcher wrote Last_Session.json, so a
secondary window's layout could replace the primary's recovery record: #19's
symptom by the in-session route, which LastSessionCoordinator only prevents at
shutdown.

LastSessionCoordinator.ownsSessionRecord(windowId) answers who may keep the
recovery files current during the session: the window a shutdown at that
moment would write for (the primary while it is open, else a live window, the
same choice saveOnProcessExit makes), and nobody while a live window is
protecting a refused restore. writeInSessionRecovery is the watcher's write:
the owner writes the record AND the set, built from the same liveSessionSet
expression the shutdown path uses; any other window writes neither. A window
running fewer than two Spaces removes an earlier set, as the coordinator does.

This replaces the earlier delete-on-first-record approach (reviewed on #1713):
there is no second writer of the set, no lock, no latch, and multi-Space crash
recovery survives restored tabs settling their titles instead of being retired
seconds after launch.

InSessionRecoveryTest drives the real coordinator and one WorkspaceManager per
"session" over a shared directory. With writeInSessionRecovery reduced to dev's
behaviour (every window writes the record, nothing refreshes the set) 3 of its
6 tests fail: the stale set wins, a secondary window writes, and a leftover set
outranks the record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): write the in-session recovery pair as one locked, uncancellable unit, set first

The owner's in-session write was two suspending writes, record then set. Closing a window
cancels its watcher before the coordinator hears of the close, so a cancellation between them
left a fresh Last_Session.json beside the stale Last_Session_Set.json that restore prefers: the
bug this change exists to fix, by another door. And the pair ran outside the shutdown write's
claim, so a Cmd+Q landing mid-pair could let the watcher's set land after the hook's, even
re-creating a set the hook had just deleted.

- LastSessionCoordinator.writeInSession: the pair is one blocking call, set first, under a lock
  the shutdown write also takes, and it re-checks the claim inside it, so a watcher arriving
  after the shutdown write adds nothing.
- writeInSessionRecovery runs it on Dispatchers.IO + NonCancellable.
- WorkspaceManager: the watcher's record write is split into a blocking file write and the list
  refresh, which stays on the caller's dispatcher; the suspend set helper is gone.
- Docs: LastSessionSet names the in-session path; ownsSessionRecord says a window closed
  mid-restore latches the same protection.
- Tests: a cancel mid-pair, a shutdown arriving mid-pair, a write after the shutdown write, and
  the refused-restore case now asserts the disk. Restored to the previous head's shape (record
  then set, two hops, no lock), 3 of the 8 fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): keep a failed in-session recovery write from ending the layout watcher

The review's one remaining item, plus the two clauses and the two tests it named.

- LastSessionCoordinator.writeInSession never throws, like claimAndWrite: the set's serialization
  runs outside the file write's own catch, and a throw from it reached the window's unsupervised
  layout watcher and ended it for the window's life, with no log line. It is now logged under
  LogCategory.WORKSPACE and answered false.
- writeInSessionRecovery keeps a set that cannot be built apart from a null set (which removes the
  file), logs it, and writes nothing, rather than letting it escape into the watcher.
- KDoc: the pair is whole because it is one non-suspending call (a cancel only lands at a
  suspension point); NonCancellable is what keeps a cancel before the IO dispatch from dropping
  the last change. writeLastSessionRecordBlocking says the list refresh is skipped when the caller
  was cancelled.
- Tests: a throwing write is answered false and the next write lands; an unbuildable set writes
  nothing; a cancel before the dispatch still records the change (pins NonCancellable); a write
  whose window closed while it waited for the lock is refused (pins the re-check inside it). With
  the four fixes reverted, exactly these four fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(keymap): show recovery notice after corrupt settings reset (#1725)

* fix(keymap): show recovery notice after corrupt settings reset

Record the preserved settings path before windows exist and offer a one-time reveal action after startup. Keep the public corrupt-file helper contract and cover preservation and delivery in regression tests.

Refs #1695

* fix(keymap): retain recovery notices until acknowledged

* fix(keymap): isolate startup helper to avoid JVM class clash

* fix(keymap): show preserved recovery path and remove stale imports

* fix(crash): swallow supabase-kt heartbeat/disconnect race instead of crashing (#1739) (#1740)

* fix(crash): swallow supabase-kt heartbeat/disconnect race instead of crashing (#1739)

supabase-kt 3.8.0 nulls the realtime socket before cancelling the
heartbeat job, so a per-tick sendHeartbeat child already in flight hits
the throwing websocket getter and escapes the library scope uncaught
(upstream #1394, fixed but unreleased by #1395). The heartbeat loop is
a SupervisorJob sibling and keeps ticking, and reconnect rebuilds the
socket, so one missed heartbeat must not pop the fatal crash dialog.

Extend CrashHandler.isIgnorable with the heartbeat-path sibling of the
existing stale-rejoin containment: exact message plus the two-frame
prefix RealtimeImpl.getWebsocket -> RealtimeImpl.sendHeartbeat, which
is unique to the tick child (sendHeartbeat is private upstream).
Ignorable-test fixtures come from the #1739 release-testing trace.

* refactor(crash): extract realtime containments, pin the two-frame boundary

detekt flagged CrashHandler as LargeClass with the new matcher, so both
supabase-kt realtime containments move to CrashIgnorableRealtime.kt,
each rule keeping its evidence comment. In the ignorable test, replace
the redundant app-frame lookalike (already covered by the renamed-
package assertion) with the cases that pin this rule's actual
boundary: an empty stack stays fatal, and an app frame past the
two-frame prefix stays ignorable by design.

* style(crash): blank line between header and first containment

---------

Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>

* fix(deep-link): stop logging Windows's expected APP_OPEN_URI gap as a warning (#1537)

Every Windows launch logged "Desktop.setOpenURIHandler not supported on
Windows" at WARN with an UnsupportedOperationException trace. The JDK
implements APP_OPEN_URI only in the macOS Desktop peer, so the call threw
on every launch, the handler it tried to register never ran, and the
comment above it ("might not work on all Windows versions") named no
version it did work on. boss:// links and file arguments reach the app
through argv instead, which processCommandLineArgs handles at cold start
and for the single-instance forward.

Remove the attempt and keep the registry setup, the one thing the Windows
branch has to do. #437 closed the same gap for the X11 peer by keeping the
call and logging the refusal at DEBUG; on Windows the call has no path to
success at all, so it goes rather than being quietened - the same reason
setupOpenFileHandler returns early on Windows.

A convention test guards the reintroduction: the macOS branch above and
the default branch below both register a handler, so adding it back to the
Windows branch reads as fixing an inconsistency, and the only symptom is a
log line on an OS no runner here uses.

* fix(db): stop a plugin's author setting its verified badge or moving it into another organisation (#1744)

`authenticated` holds table-level INSERT and UPDATE on public.plugins, and the
row policies let an author write their own plugin row. The policies gate the
row, not the columns in it, so an author could:

- set `verified` (the admin-only checkmark, written by the plugin-store admin
  route behind plugins.admin.publish) with a direct UPDATE, or INSERT a new row
  already verified, since the INSERT policy never checks the column;
- change `org_id` on their own plugin to any organisation, including one they
  do not administer: the UPDATE policy's `auth.uid() = author_id OR
  is_org_admin(org_id)` short-circuits on the author clause.

The migration replaces each client role's table-level grant with column-level
grants on every current column except `verified` (INSERT and UPDATE) and
`org_id` (UPDATE only; INSERT stays under the policy's can_publish_org_plugin
check), read from the catalog as 20260918000000 does, and fails closed if the
privilege survives through PUBLIC or an inherited role. The plugin-store edge
function uses the service-role key and is unaffected; nothing in this repo
writes the table as a client.

plugin_trust_columns_test.sql (13 assertions): on unpatched dev 7 fail, with
the verified UPDATE and INSERT and the foreign-org UPDATE all landing. Full
pgTAP suite: 50 files, 1151 tests, pass.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test(db): pin the exact plugins columns closed to clients, and say how the next one is closed (#1747)

The three non-blocking follow-ups fluck-boss left on #1744's merge.

- plugin_trust_columns_test.sql pins the closed set exactly: {verified, org_id} for UPDATE and
  {verified} for INSERT, for authenticated and anon. A column added to public.plugins is not
  client-writable until a migration grants it; now it fails the suite until someone decides
  whether clients may write it, instead of landing closed without anyone noticing. With a
  `featured` column added, the four new assertions fail (`have: {featured,org_id,verified}`).
- The migration header names the "Users with plugins.admin.publish can update any plugin" policy,
  so "the edge function is the one legitimate writer" can be checked against admin tools
  outside this repository.
- The header says the block cannot be copied to protect a third column: it acts only on a
  table-level grant, and none remains after it runs. With per-column grants, a later migration
  revokes the one column directly.

Comments only in the applied migration; its statements are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(spaces): resolve named-save ownership at press time (#1749)

* fix(dashboard): keep recent-file dismissals through startup loads (#1751)

* refactor(dashboard): generalize in-flight list load guard

* fix(dashboard): preserve file dismissals during startup

* test(dashboard): cover file dismissal load races

* fix(auth): redact passkey IDs and narrow route diagnostics (#1760)

* fix(logging): keep private local state out of decoder logs (#1762)

* fix(logging): redact local-state decode failures

* fix(workspaces): keep private layouts out of decode logs

* fix(plugins): replay window registration snapshots on restore (#1356)

* fix(plugins): replay window registration snapshots

* fix(plugin): prepare a registration before dropping its predecessor

register() removed the owner's live entry before running target.prepare;
both current prepares catch Throwable and the rest are identity, so nothing
throws today - but any future fallible prepare would strand the served
registration with nothing published. Preparing first keeps the old entry
serving on a throw, at no cost to the success path.

* test(plugins): preserve registration after snapshot failure

---------

Co-authored-by: Instinct Agent <agent@instinct.com>

* fix(editor): prevent stale navigation target replay (#1405)

* fix(editor): unify navigation target replay ownership

* test(editor): cover navigation delivery contract

* test(editor): isolate navigation IPC coverage by platform

* test(editor): cover runtime serialization and burst capacity

* fix(dashboard): require guarded list mutations (#1764)

* :arrow_up: deps:(deps): bump kotlin from 2.4.10 to 2.4.20 (#654)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin.jvm
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.multiplatform
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.plugin.compose
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.plugin.serialization
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin:kotlin-test-junit
  dependency-version: 2.4.20
  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>

* fix: complete glass settings search after dev integration

* fix: handle recovery write failures and reject ZIP directory payloads

* fix(plugin-store): let organisation members download their organisation's plugins (#1596)

* fix(plugin-store): let organisation members download their organisation's plugins

Both download routes looked the plugin up with get_plugin_with_stats, which
resolves visibility for auth.uid(). The edge function holds a service-role
client, so auth.uid() is NULL and every `org` or `unlisted` plugin came back
as no row. The route answered "Plugin not found" before canInstall() saw the
caller, so no member could install or update their organisation's plugins.

The download lookup now reads the published plugin row directly and applies
no visibility; user_can_install_plugin stays the only gate, as the routes
already intend. Passing the viewer to get_plugin_with_stats_for_viewer would
fix `org` but not `unlisted`, because viewing is deliberately narrower than
installing. A refused caller still gets the same 404 as a missing plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(plugin-store): cover API key download visibility

* refactor(dialogs): split clone progress content

* fix(plugin-store): mark download responses private; close review test gaps

- Cache-Control: private, no-store on both download 200s: the body can
  describe an org plugin and carries a signed URL on a caller-less path.
- Correct the getPluginForDownload KDoc: published = true is the only
  publication guard, since the install predicate admits authors/admins.
- Tests: per-lookup filter recording, version-sensitive version stub
  (unknown version 404s), required-permission 403, lookup error 500
  without echoing the DB message, Cache-Control assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

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

* Stop a sleeping laptop from emptying the window overnight (v2, supersedes #1207) (#1771)

* Stop a sleeping laptop from emptying the window overnight

Two defects, in series, that between them lose a night's tabs.

PluginWatchdog judged heartbeat age on the raw wall clock. Stall
suppression is deliberately bounded (maxSkippedChecks), and a laptop
sleeping in quarter-hour bursts reaches that bound; the tick past it
then failed every plugin on an age that was almost entirely the host's
sleep, spending restart budget until a plugin was disabled and its open
tabs went with it. The watchdog now banks the suspension it measures and
subtracts it from heartbeat age.

The credit is cleared by a FULLY AWAKE tick carrying a beat. An anchor
on "newer than the tick start" is unsound: the last beat before a lid
closes lands inside the same tick that spans the suspend, so that test
discards the credit for the very sleep being measured. Only suspension
is credited, never frozen-while-awake overrun.

The credit is also spent by AWAKE time, not held until a beat. Without
that bound a plugin that dies during a sleep - worst case one that beats
once on the resume, so its sleep is counted both in the heartbeat age
and in the credit - buys itself the whole night before anyone asks about
it again. Continuous awake time with no beat now clears the credit once
it passes the unhealthy threshold, so detection is bounded by how long
the host has actually been RUNNING, whatever the wall clock did. The
same bound is what keeps a wall clock stepped forward by NTP, which is
indistinguishable from a suspend by the two-clock test, from delaying a
wedged plugin's restart by more than that.

Second, the layout watcher wrote whatever was on screen, so the moment a
tab-owning plugin was unloaded it put "tabs": [] over a full session -
the write that turned a recoverable window into a lost one.
layoutWatcherWrite now returns a null record for a layout with no tabs,
so the type forces the call site rather than leaving the condition to be
re-implemented there. The in-memory current workspace still tracks the
live layout, because a plugin's Save reads it.

Residual window, accepted: between a deliberate emptying of the window
and the next dispose or clean exit, a SIGKILL or JVM crash now restores
the last non-empty record instead of the empty one. Restoring tabs the
user had closed is a far cheaper failure than silently discarding tabs
they had not.

Also: skipping the write leaves reportUnsaved comparing against the
stale disk copy, so an emptied window reads as unsaved until dispose
writes it. Left alone - Last Session is not a document the user saves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Keep the watchdog's awake bound across sleeps

Review follow-up from #1207.

The awake counter that spends suspension credit was reset by every
suspended tick. A dead plugin on a laptop that wakes for less than the
unhealthy threshold never reached the bound, and in the double-counted
case (a beat on the resume) kept a whole sleep of negative age for
roughly that long in awake time. The counter is now cumulative, reset
by a beat alone.

Each tick adds at most one interval to it, so a freeze right after a
resume beat cannot spend the sleep's credit in one tick and restart a
plugin that is alive.

Also: drop an assertion in LayoutWatcherWriteTest that could never fail,
cover a tab nested two splits deep, make hasAnyTab private, and document
the claim-once and hard-crash residuals on the empty-layout guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(logging): pin the path-embedded protocol-relative leak and the maskUriParams side of #1663 (#1770)

Document the accepted residual path-embedded userinfo leak and pin its expected output in a regression test. Add maskUriParams coverage for the protocol-relative redaction from #1663. This does not fix the residual leak; its tracker remains open.

Refs #1665

* fix(terminal): closeStdin cancellation check, clarify IPC 1.4.0 load gate, tighten tests (#1777)

* fix(terminal): re-check coroutine activity after acquiring input lock in closeStdin

Issue #1677 item 1:
- Add currentCoroutineContext().ensureActive() right after acquireInputLock
  in closeStdin().
- If the caller was cancelled while parked on the input lock behind a slow
  writer, abandoning the call does not proceed to deliver EOF to stdin once
  the lock becomes available.

* docs(ipc): clarify IPC 1.4.0 minIpcVersion load gate semantics

Issue #1677 item 2:
- Clarify that an older host answers CloseInput with UNIMPLEMENTED only if
  the runtime declared minIpcVersion <= host.
- A runtime declaring minIpcVersion: 1.4.0 is refused at spawn time by
  <= 1.3.0 hosts.
- Drop misleading 'Like 1.0.0 below' comparison.

* test(ipc): split CURRENT version pin into dedicated test

Issue #1677 item 3:
- Split CURRENT == 1.4.0 pin out of the minIpcVersion compatibility test
  into a dedicated test current IPC version is 1_4_0.
- Keeps the version pin test cleanly distinct from the compatibility logic test.

* test(terminal): wrap join in withTimeout and add closeStdin cancellation test

Issue #1677 items 1 & 4:
- In TerminalCloseInputTest, wrap unbounded writer.join() in withTimeout(5_000)
  inside the busy test finally block so a hung writer cannot stall CI indefinitely.
- Add comment documenting that 'closing input twice is a no-op' pins the RPC-level
  idempotence contract.
- Add unit test 'cancelling a queued closeStdin abandons closing input' in
  TerminalSendQueueTest asserting that cancelling a coroutine queued on the input lock
  does not close stdin once the lock is acquired.

* refactor(auth): rename createLockFileExclusively and retain atomicWriteText for session key (#1675) (#1773)

Clarify lock sidecar creation by renaming createKeyFileExclusively to createLockFileExclusively and its parameter to lockFile. Retain atomicWriteText for session key publication under the same persistent sidecar lock. Add a desktop regression test for the resulting key and lock-file owner permissions.

The earlier CREATE_NEW final-key write in this branch was reverted before merge; it is not part of the resulting change.

* fix(terminal): release input mutex when acquire is cancelled (#1778) (#1779)

- Catch CancellationException in acquireInputLock and unlock ticket if held before rethrowing
- Update closeStdin KDoc to document post-admission ensureActive semantics
- Guard writer.join() with withTimeoutOrNull in TerminalCloseInputTest
- Add regression test for uncontended acquire cancellation in TerminalSendQueueTest

* fix(windows): preserve localhost authority for genuine UNC share paths (#1645) (#1772)

* fix(windows): preserve localhost authority for genuine UNC share paths (#1645)

* Refactor CodeSourceLocation to chain recoverCatching and tighten tests

* fix(plugin-store): gate direct version reads on finalized rows (#1630) (#1775)

Gate direct plugin_versions reads on finalized rows while retaining the existing plugin-visibility policy. Add pgTAP coverage for public, org, and unlisted versions across anon, outsider, member, author, admin, and service-role access, including pending and half-finalized rows. Handle finalized version UPDATE events in PluginStoreRealtimeService and test the client handler.

Deployment checks remain: audit existing real-sha versions with zero or NULL jar_size before applying this migration, and verify finalized UPDATE delivery through a live realtime client. The UI refresh callback and authenticated org realtime socket are separate follow-ups, not verified by these unit tests.

* fix(plugin-store): refuse over-cap JAR reads on declared size, cover the inflate cancel branch (#1783)

Add pre-read declared-size refusals for remote JAR ranges and EOCD probes, cancel refused response bodies, and catch writable-side decompression rejections.

Tests exercise actual remote inflate-limit cancellation, finite over-cap EOCD/CD refusal streams, and release-asset refusal with zero reads and an observed cancel callback.

Reviewed at 51d72cc against dev 1d381e6 with clean author-only changes, completed exact-head second-layer review, and 18 successful checks including Plugin Store, pgTAP and build-test. Broader remaining bounds work is separate; this change does not claim to close every memory-exhaustion path.

* fix(mcp): harden secret references against casing, plaintext leaks, and drift (#1672) (#1784)

* fix(mcp): harden secret references against casing, plaintext leaks, and drift (#1672)

- McpJsonDepth.kt: Enumerate all four guarded parses on invoke path in KDoc
- SecretReference.kt: Support case-insensitive {{secret:...}} prefix matching
- McpArgumentSanitizer.kt: Expand VALUE regex to avoid premature stop at '}', and redact malformed {{secret:...}} candidates
- McpSecretPrePass.kt: Sanitize literal in malformed secret reference refusal
- McpToolRegistryImpl.kt: Log only exception class name in parseMcpToolArgs failure
- docs/MCP_SECRET_REFERENCES.md: Document case-insensitive prefix handling
- Tests: Add coverage for case-insensitivity, redaction of malformed candidates, and stack safety

* fix(mcp): resolve review blockers on secret reference hardening (#1672)

- Redact unterminated and nested malformed candidates in McpArgumentSanitizer
- Stop echoing raw agent-supplied field name in SecretReferenceParser refusal reason
- Sanitize both scan.literal and scan.reason in McpSecretPrePass
- Use decodeFailure(e) and handle SerializationException in nonObjectArgsError and parseMcpToolArgs
- Allow adjacent valid secret references in sensitiveAssignment
- Expand test coverage with unterminated shapes, adjacent references, and casing fuzzing
- Update KDoc and documentation in MCP_SECRET_REFERENCES.md

* fix(mcp): resolve review blockers on unterminated references and decode logging

- Make malformedSecretReference regex tolerant of unterminated candidates and bound at closing brace
- Allow repeated adjacent valid secret references in sensitiveAssignment lookahead with possessive group
- Report offset and length in SecretReferenceScan.Malformed and omit raw candidate text in refusal descriptor
- Stop echoing unverified fieldPart verbatim in SecretReferenceParser.parseBody
- Sanitize scan.reason in McpSecretPrePass.prepare
- Use decodeFailure(e) for SerializationException in nonObjectArgsError and parseMcpToolArgs
- Add unit tests for unterminated shapes, adjacent valid references, wireName alternation pinning, and small-stack safety
- Update KDocs and docs/MCP_SECRET_REFERENCES.md

* fix(cli-installer): bound Windows PATH update process timeout (#1254) (#1786)

* fix(cli-installer): bound Windows PATH update process timeout (#1254)

- Bound setx process.waitFor with 30s timeout and destroyForcibly on expiration
- Drain stdout and stderr asynchronously on daemon threads to prevent pipe buffer deadlock and capture failure diagnostics
- Return false and log warning with captured streams on timeout or non-zero exit code
- Extract executeSetx to maintain clean separation and obey linter complexity limits
- Add CLIInstallerWindowsPathTest unit and regression tests covering success, failure, timeout, and pre-existing PATH paths

* fix(cli-installer): destroy child on exception and discriminate async drain in tests (#1254)

- Clean up child process and drain threads in executeSetx finally block
- Restore Thread.currentThread().interrupt() on InterruptedException
- Require positive timeoutSeconds in updateWindowsPath and executeSetx
- Move drain thread joins above finished branch for reliable diagnostics
- Make top-level constants private
- Add mutation-discriminating test using FakeProcess stdout blocking until destroyForcibly
- Add interruption and argument validation tests

* fix(cli-installer): add test timeouts, safe interrupt clearing, and log before interrupt restore (#1254)

* fix(workspaces): redact decode failures on CLI workspace load (#1711) (#1788)

* fix(workspaces): redact decode failures on CLI workspace load (#1711)

- In BossAppEventBusEffects, handle SerializationException separately from
  general exceptions when deserializing Space JSON from WorkspaceEventBus.
- Use decodeFailure(e) to log structured offset and exception-type diagnostics
  without leaking the offending Space JSON (tab URLs, project paths, terminal commands)
  into host logs.
- Retain throwable on ordinary I/O exceptions in catch (e: Exception).
- Extract subscribeWorkspaceLoadEvents to enable window-filtered event testing.
- Add listener-level regression tests in WorkspaceDecodePrivacyTest covering:
  * Redaction of private layout data on corrupt Space CLI loads
  * Filtering of events destined for other windows
  * Successful dispatch and zero-warning logging for valid Space loads
  * Retention of throwables on ordinary I/O errors

* fix(workspaces): deterministic subscriber readiness, distinct spacePath key, and cancellation preservation (#1711)

- Expose WorkspaceEventBus.subscriptionCount to allow tests to synchronize
  subscriber registration deterministically before event emission.
- Use 'spacePath' key in log metadata to avoid key collision with decodeFailure's 'path'.
- Rethrow CancellationException in subscribeWorkspaceLoadEvents to preserve coroutine cancellation.
- Replace arbitrary delays in WorkspaceDecodePrivacyTest with CompletableDeferred signals,
  cancelAndJoin lifecycle, and deterministic readiness assertions.

* test(auth): observe and make visible Windows inconclusive path in UserDataStorageMergeTest (#1676) (#1791)

- Invert comment-only empty if branch into assertNonVacuousRace helper

- Log 'inconclusive on this leg' with observed form count when Windows file handle contention blocks atomic writes

- Add unit tests pinning platform non-vacuity behavior for both landed and unlanded writes

- Fixes #1676

* fix(wizard): align stageAndInstall in-place cleanup with refusal premise (#1648) …
kshivang added a commit that referenced this pull request Oct 9, 2026
…uance, ISO 4217 amounts (replaces #1769) (#1830)

* fix(settings): recover corrupt keymap, zoom and workspace settings files, and lock zoom updates (#938)

Co-authored-by: abhinavlenka <abhinavlenka@users.noreply.github.com>

* fix(plugins): follow a Downloads folder the user moved on Windows

Windows lets the user move Downloads (Explorer, Properties, Location), for
instance to D:\Downloads, but BOSS kept answering %USERPROFILE%\Downloads.

- WindowsDownloadsFolder reads the shell's value for FOLDERID_Downloads with
  reg.exe, parses REG_SZ and REG_EXPAND_SZ, expands %NAME% variables and keeps
  only an absolute path. It needs no native binding, so the host and plugin
  processes read it the same way. Read once per JVM; a missing value, a
  missing reg.exe or a run past 5 s fall back to the old answer. It is only
  consulted when user.home is the Windows profile, so test tasks that
  redirect user.home never read the registry.
- DownloadsDirectory.current() passes it as the known folder, which still
  wins only when it names a real directory.
- FileSystemDataProviderImpl.writeFile/readFile admit the Downloads folder the
  provider hands out, and paths under it, in addition to home. Compared on
  real paths, so .., symlinks and junctions cannot lead into a sibling; a
  Downloads folder at a filesystem root admits nothing; delete stays
  home-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): log an unread Downloads known folder once, and make two reader tests test what they say

Review follow-up for this PR.

- The desktop getDefaultDownloadsDirectory logs once per JVM when the Windows
  known folder could not be read, so a failed registry read is no longer
  indistinguishable from a folder that was never moved. plugin-path-utils stays
  logger-free.
- The commonMain expect KDoc no longer says Windows is always
  %USERPROFILE%\Downloads.
- WindowsDownloadsFolder.current checks os.name itself instead of relying on
  its only caller to.
- "a successful run returns its standard output" ran java -version, which
  prints to stderr, so it passed on empty output; it now runs --version and
  checks the version is in what came back (a planted stdout-dropping mutation
  fails it). The timeout test uses a program that sleeps 60 s against a 1 s
  timeout instead of a 0 ms race.
- RelocatedDownloadsAccessTest's outside-home premise is a @BeforeTest
  assumption, so it gates every case instead of only itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(build): ship plugin-path-utils with the upstream jars plugin-api-ipc needs

plugin-api-ipc calls DownloadsDirectory from plugin-path-utils at runtime
(FileSystemDataProviderProxy), but assembleUpstreamJars shipped only four jars,
none of which contains it. The standalone microkernel runtime would compile
against plugin-api-ipc and then fail with NoClassDefFoundError on the first
getDownloadsDirectory() call.

- assembleUpstreamJars now also ships plugin-path-utils-<ver>.jar, with the
  same dependsOn and rename trick as the other plugin-platform jars, and the
  floor on produced jars is 5, so a missing one fails the task.
- plugin-path-utils gets version 1.0.0, like plugin-api-ipc and
  plugin-api-core, so its jar has a versioned name the runtime can pin.
- The task KDoc and the release.yml comment list the fifth jar.

Before: 4 jars, none containing ai/rever/boss/plugin/pathutils. After: 5 jars,
plugin-path-utils-1.0.0.jar contains DownloadsDirectory.class and
WindowsDownloadsFolder.class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): refuse a Downloads path that cannot be resolved, and make the timeout test time itself

Review follow-up for this PR.

- isInsideDownloads fails closed: an IOException from canonicalFile or
  toRealPath now makes the check return false, so the plugin gets the
  SecurityException refusal instead of a generic I/O failure.
- realPath climbs with Files.exists(NOFOLLOW_LINKS), so a dangling symlink or
  junction inside Downloads is found as the link it is; toRealPath on it fails,
  and the write is refused instead of being checked as a plain name and then
  followed. The KDoc says so.
- The timeout test asserts the elapsed wall time is at least the timeout, so a
  child that fails at once (which also gives null) cannot pass it.
- A comment on the home branch of isReadableAndWritable says it compares
  canonicalFile paths and is knowingly weaker than the Downloads branch.
  Behaviour unchanged.

Seen red first: on the previous code both new RelocatedDownloadsAccessTest
cases fail (IOException: Invalid file path; FileNotFoundException through the
dangling junction), and the timeout test with a child that fails at once
fails after 124 ms, where the old version of the test passed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(auth): pin current passkey attempt and auth arguments (#1691)

* test(auth): pin current passkey attempt and arguments

* test(auth): preserve superseded attempt loading assertion

* fix(performance): clamp the plugin heap before bounding the initial heap with it (#1696)

PerformanceSettings.validated() bounded pluginJvmInitialHeapMb by the stored
pluginJvmHeapMb, not the clamped one. A stored max below 32 made the range
empty, coerceIn threw, and PerformanceSettingsManager's load fell back to
defaults, dropping every performance setting in the file over one bad value.
A stored max above 8192 let the initial heap end up above the clamped max.

Clamp the max heap first and bound the initial heap by that value.

Fixes #1000

* test(db): pin the viewer wrapper's page-size floor, and say LIMIT 0 changed (#1671 follow-up) (#1700)

#1671's re-review found the one clamp term its test left unpinned: GREATEST(..., 1) on
search_plugins_for_viewer's page size. Without it, the null and 101 cases still give 20
and 100, so a viewer-only typo there would ship. One assertion at 0 pins it, mirroring the
search_plugins block.

The page-size-0 case also gets a comment saying the behaviour changed. LIMIT 0 is legal, so
a direct call used to return an empty page with a correct total_count; it now returns one
row, matching get_popular_tags. The note lives in the test rather than in the merged
migration, which is already applied.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test: fail on skipped tests in every module and in the IDE, not only composeApp's Gradle run (#1699)

Review follow-up on #1667, which merged before these landed.

- The discovery-issue guard moves from composeApp's Test tasks to the root allprojects
  block, so every module's Gradle test run fails on a @Test JUnit will not execute.
  Checked by adding a value-returning test to plugin-loader, whose run then failed with
  "must not return a value".
- composeApp repeats it in src/desktopTest/resources/junit-platform.properties, which
  JUnit reads however it is launched, so an IDE run meets it too. Checked with the root
  property removed: the same probe in composeApp still failed the run.
- The build comment says what a failure looks like (discovery aborts for the whole engine,
  and one initializationError names the method) and to relax the property to ERROR rather
  than delete it if a toolchain bump brings an unrelated warning.
- AGENTS.md records the rule beside the composeApp test-home note: declare an
  expression-bodied test `(): Unit =`, or write runBlocking<Unit>.
- McpApprovalGateTest's buffer test waits on pendingList instead of a 50 ms sleep, as its
  deny-all sibling already does. The unknown-surface test now observes the no-op: nothing
  appears, and a real surface survives.

All 29 modules' test tasks with the guard on: 8,414 tests, 0 failed. ktlint and detekt
clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugin-store): the #1508 tidy-up - one ceiling per bound, a real 400 body, paged routes bounded (#1669) (#1702)

All seven items from #1669, the follow-ups to #1508.

1. The popular-tags handler's Number.isInteger guard was dead: the route's schema validates
   before the handler runs, so `limit` is already an integer in 1..100. It is deleted, the
   handler reads `const { limit } = ctx.req.valid("query")`, and the NaN / LIMIT NULL
   rationale moves onto the schema. The handler's comment claimed an invalid limit still
   spent rate-limit budget, and /search's claimed the limit was consumed before parsing;
   both were untrue for the same reason and now say what happens.
2. browse gets a defaultHook, so a request that fails its schema is answered with the
   ErrorResponseSchema { error } every route declares, naming the field, rather than the
   validator's own { success: false, error: <ZodError> }.
3. Each ceiling is defined once in types/schemas.ts: POPULAR_TAGS_LIMIT_MAX,
   CATALOGUE_PAGE_SIZE_MAX, CATALOGUE_PAGE_MAX. The popular-tags query schema moves there as
   PopularTagsQuerySchema.
4. schemas.test.ts imports that schema instead of restating it, so moving the route's cap
   can no longer leave its tests green. It also pins the cap's edge.
5. /list declares its 400. catalogue-paging-bounds.test.ts checks /list?pageSize=999999999
   gets that 400 before the database.
6. Both routes bound `page` (1..10,000) and /search gains .int() on page and pageSize.
   A deeper page is a 400 instead of an empty page from the SQL clamp.
7. The popular-tags recorder keeps the RPC name and asserts get_popular_tags.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test(settings): fail on any settings or state writer that falls back to writeText (#1701)

A regression watch for #1022, which routed thirteen writers through atomicWriteText
(#1011, #1659) with a test for only one of them. Asked for when #1661 was closed as
superseded.

SettingsAtomicReplaceTest has one case per writer. Each saves through the API the app
uses and asserts the file got a new identity: a file rewritten in place keeps its inode,
and one replaced by rename gets a new one.

- The old inode is pinned with a hard link before the measured save, because ext4 hands a
  just-freed number to the next file created and a correct replace could compare equal.
  That happened once on ubuntu in #1661.
- The file is created first when absent, so a writer that loads on a background thread
  reads it rather than writing a default that would hide a reverted save.
- Installed plugins adds its own entry and removes it, leaving the shared installed.json
  as it found it. The HTML and plugin-state stores run on temp files.
- Windows exposes no fileKey, so the cases are skipped there with the reason.

Mutation-checked against a fresh test home: switching each of the thirteen files from
atomicWriteText to writeText fails exactly that file's case, 13 of 13. 1,181 tests across
the settings, plugin, html and filetypes suites pass; ktlint and detekt clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): make the corrupt-file aside helper non-throwing, and pin its collision path (#1704)

Follow-ups from the #938 merge review.

renameAsideCorrupt promised best-effort failure through its return value,
but toPath() throws InvalidPathException for a name the filesystem cannot
represent, and the move and the permission narrowing can throw
SecurityException. The three settings managers call it from recovery paths
their object initializers reach, so an escape failed the whole manager.
Both are now caught and logged, and the call returns false (#1692).

The stamp can be supplied, so the collision test pre-creates the first
aside and checks the retry to -1 keeps both copies, instead of relying on
two moves landing in one millisecond. A second test fills every suffix and
checks that nothing is overwritten (#1693).

The keymap Json comment now says that coercion is not a round trip: the
older build's next write persists the default, so a downgrade still loses
that one field, and a test pins it (#1694).

Fixes #1692
Fixes #1693
Fixes #1694

* fix(plugin-store): every router answers a schema failure with the 400 body it declares (#1705)

#1702 gave `browse` a defaultHook, so a request that fails its schema gets the
ErrorResponseSchema { error } its routes declare. The other five routers (publish,
api-keys, rating, download, admin) still answered with the validator's own
{ success: false, error: <ZodError> }. That body isn't the declared shape, and it hands the
caller the schema's internals. The #1702 review found this gap.

- utils/router.ts: newRouter() builds every plugin-store router with the hook and its
  invalidRequestMessage helper, moved out of browse.ts. It is a factory, not an app-level
  hook, because the hook is bound into a route's validators when router.openapi()
  registers it: a hook on the app in index.ts would never reach a sub-router's routes.
- All six routers use it.
- tests/router-hook.test.ts provokes one schema failure per router that has one (browse,
  rating, admin, api-keys, publish), using a client whose database and auth calls throw.
  Each must return 400 with only an `error` key, naming the field. download's routes take
  only path strings, which nothing fails, so a source scan covers it: no route file may
  construct OpenAPIHono directly, and the six routers must all be built by newRouter.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test: the #1699 and #1701 review follow-ups - buildSrc's guard, clearer settings-test failures (#1708)

Both PRs merged before their non-blocking review points could land.

#1699:
- buildSrc sets junit.platform.discovery.issue.severity.critical=WARNING itself. It is a
  separate build the root allprojects block never reaches, and its tests run in CI's own
  `./gradlew -p buildSrc test` step. All four buildSrc test classes pass with it on.
- The root comment and AGENTS.md say an override belongs in the module's own
  build.gradle.kts, which wins, and that buildSrc carries its own copy.

#1701:
- An unchanged identity is now reported as one of two things, told apart by the file's
  modified time: rewritten in place, or not written at all. The writers log a failed save
  and swallow it, so the old single message sent a reader after a writeText that wasn't
  there. Checked by forcing both.
- The class KDoc records that each measured save is assumed to write unconditionally.
- The default-apps case restores the test-home file and resets the manager, since it is
  the one case that changes shared state.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* build(buildSrc): pin JUnit to the root's version so the discovery-issue guard is live (#1709)

#1708 added junit.platform.discovery.issue.severity.critical=WARNING to buildSrc, and its
review asked whether that line was actually read. It wasn't. buildSrc's only test dependency
was kotlin("test"), which resolves JUnit 5.10.1 / Platform 1.10.1 through Gradle's embedded
Kotlin. The property arrived in Platform 1.13, so nothing read it: a value-returning @Test
in buildSrc was still skipped with the build green.

- buildSrc/settings.gradle.kts imports the root version catalog.
- buildSrc/build.gradle.kts adds the JUnit BOM at libs.versions.junit.jupiter (6.1.3, the
  root's) and the platform launcher. Every JUnit artifact resolves to 6.1.3, and a later bump
  of the root moves buildSrc with it.

Checked both ways with a throwaway `fun probe() = 42` test in buildSrc. On dev it passed
silently (exit 0); with this change it fails with "must not return a value". buildSrc's own
43 tests pass, the root build configures, and ktlint and detekt are clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): record escalated shell calls in the ledger, and match format only as a command (#1655) (#1698)

* fix(mcp): record escalated shell calls in the ledger, and match format only as a command

Two of the #1655 leftovers the Linux seat testing still hits.

The ledger row of a CRITICAL shell call that a saved ALLOW did not cover
now carries escalated = true. Under YOLO mode such a call is answered
YOLO_ALLOWED with policyApplied = ASK, exactly like a routine one, so an
audit could not tell a destructive call that ran unattended from any
other. The field is hashed only when set, like secretRefs, so every
existing record keeps its hash; boss mcp ledger tail/search show it.

The registry states the escalation instead of inferring it from
policy != savedPolicy, so the prompt and the ledger row read one answer.

The evaluator matched the text "format " anywhere, which rated
--format json, clang-format and English typed through send_input
CRITICAL and made a saved Always Allow ask again for all of them. format
now counts only as the command word of a segment, or when followed by a
drive such as d:.

Refs #1655

* fix(mcp): address the #1698 review - format with leading switches, and the escalated flag's scope

- format with its switches before the volume (cmd /c format /q e:) rated
  HIGH, because only the token right after format was checked for a
  drive. The whole rest of the segment is scanned now; none of the false
  positives the change removed carries a drive letter.
- The escalated flag means the destructive-shell gate overrode a saved
  ALLOW, not that a call was destructive. Under the default ASK policy a
  destructive call YOLO answers records false. The record KDoc and
  AGENTS.md now say so, and a test pins that case. The KDoc also names
  the secret-policy case and the downgrade caveat for ledger verify.
- authorizeInvocation's escalated parameter loses its default, so a new
  caller has to answer.

* docs(agents): say the discovery-issue guard needs JUnit Platform 1.13, and why buildSrc pins the BOM (#1710)

Requested on #1709. junit.platform.discovery.issue.severity.critical is silently ignored on
JUnit Platform older than 1.13, and a passing run looks the same either way. buildSrc's BOM
pin (#1709) is what makes its guard live, so AGENTS.md now says so, and says how to check
a module with a throwaway value-returning test. That keeps a later "redundant dependency"
cleanup from quietly making the guard inert again.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(logging): log a failed decode by type and path, never with the file's content (#1629, #1695) (#1703)

* fix(logging): log a failed decode by type and path, never with the file's content

kotlinx puts the document it failed on into the exception message, and
even the diagnostic before it can quote a value. Logging those exceptions
as error = e wrote visited URLs with their query strings (recent pages,
#1629), the domains a user zoomed, and keymaps into the log that people
attach to bug reports (#1695).

decodeFailure(e) returns log fields with only the exception type, the
offset and the JSON path, with map keys masked since the zoom settings key
by domain. The recent-pages load and history bootstrap, the three
corrupt-file paths from #938 and the keymap import now log it instead of
the exception. SerializationException is caught ahead of the general
catch, which it would otherwise fall into as an IllegalArgumentException.

Fixes #1629
Refs #1695

* fix(logging): address the #1703 review - read markers from the diagnostic only, never throw

- The offset and path are read from the diagnostic, never from the
  document kotlinx appends after "JSON input:". The offset must lead the
  message, before anything kotlinx quotes, and an out-of-range number is
  dropped with toIntOrNull: a throw here ran inside the caller's catch
  and skipped its recovery.
- The path comes from the LAST marker on the first line, since kotlinx
  appends the genuine one after any value it quotes, and after masking it
  must be pure structure or it is left out. A marker spelled by a value
  or hidden in a key is not logged.
- Map keys are masked by index from the first [' to the last '], so a
  key holding '] or a Unicode line separator cannot end the mask early.
- The rest of the #1629 data and its neighbours: UrlHistoryManager (the
  browser-history.json that recent pages bootstraps from), ResolvedHostsStore,
  RecentFilesManager and SelfHealingSettingsManager's settings load now
  log decodeFailure(e) too.

* fix(logging): stop user_data.json decode failures logging the user's email and id

Review on #1703: UserDataStorage's two corrupt-record arms logged the
decoder's exception, whose message quotes the record - the user's email
and id. Both now log decodeFailure(e), and a test drives a torn record
through both paths and checks no entry carries the exception, the email
or the id.

Also from the review:
- A quoted value with a newline moved the genuine path off the first
  line, leaving the value's own marker last. An odd number of quotes
  before the marker means the line ends inside a value, so the path is
  left out.
- The offset scan is bounded to the start of the diagnostic.
- The KDoc says a non-identifier serial name drops the path too, and the
  space-in-key test comment describes the current masking.

* fix(logging): cover the last two user_data.json decodes

Review on #1703: loadUserData (the startup path, at ERROR) logged the
decoder's exception, and readStoredWizardFlag logged its toString(),
which is the same message by another route. Both quote the record, the
user's email and id. Both now log decodeFailure(e).

The torn-record test now drives all four decodes - loadUserData,
saveUserData's stored-flag read (at DEBUG), and the two wizard paths -
and checks each logs once with no exception, and no entry carries the
email or id.

Also: the odd-quote KDoc says it is a heuristic a value's own
apostrophes can defeat, bounded by the structural check; and the
AGENTS.md bullet forbids the decoder's message by any route and points
at #1711 for the older sites.

* fix(mcp): preserve legacy provider DENY rules (#1682)

* fix(mcp): keep a long argument from crashing the audit record of a call that already ran (#1712)

* fix(mcp): keep a long argument from crashing the audit record of a call that already ran

McpArgumentSanitizer.sanitizeMessage builds the ledger record inside invoke's
finally, after executeAuthorized has run, and the approval request before a
prompt. Its PEM rule repeated three GROUPS greedily - `(?:\s|\\n)*`, the
header-line body and the header lines themselves - and java.util.regex recurses
once per iteration of a greedy group repeat. `-----BEGIN PRIVATE KEY-----`
followed by 4,000 spaces overflows a default thread stack. The schema gate
ignores keys no schema declares, so a call under any ALLOW (saved rule, trusted
plugin, session trust, YOLO) could carry that payload next to its real command:
the tool ran, the ledger lost the row, no dropped-write was counted, and invoke
threw a StackOverflowError to its caller. With a prompt, the approval path
overflowed first and the call died unlogged. sensitiveAssignment's name suffix
`(?:[_-][A-Za-z0-9]+)*` overflows the same way on `password_a_a_a...`.

Every repeated group in the rules is now possessive (`*+`), which the engine
runs as a loop. Nothing that may follow any of those groups can be a character
the group consumed, so no match needs one of them to give anything back, and
the redactions are unchanged: the fuzz, auth-scheme, credential-shape, URI and
secret-reference suites pass as before.

sanitizeMessage also catches StackOverflowError and records
`[OMITTED: could not be sanitized]`: a backstop for a rule added later without
that care, which withholds the value rather than showing it raw.

McpArgumentSanitizerStackSafetyTest states the property: every rule's trigger
(25) followed by every pathological filler (18), at the ledger's 16,384-character
cap and at four times it, sanitizes on a 256 KB stack without throwing and
without reaching the backstop. Against dev it fails 62 cells, all
StackOverflowError, and both registry-level tests (a call run under ALLOW keeps
its ledger row; a prompted call reaches the operator and the ledger) fail with
the StackOverflowError escaping invoke.

AGENTS.md records the rule for anyone adding a sanitizer pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: leave AGENTS.md to the maintainers

The rule this change relies on is stated where it lives, in the KDoc beside
the code; AGENTS.md policy text is not merged from contributor PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kernel): check the requested action against the registered manifest before dispatch (#1716)

invokeCapability checked only that the target process was registered,
never that it advertised the requested action, while listCapabilities
advertised from the same registeredProcesses table - so the set the
kernel dispatched was strictly wider than the set it advertised. Check
the requested action against the registered process's own manifest at
the invokeCapability seam, before the broker is consulted:

- read registeredProcesses[pluginId] once;
- refuse "Process not found: <id>" when absent;
- refuse "Plugin <id> does not advertise capability: <action>" when the
  manifest has no matching action;
- only then hand the request to the broker. Advertise and dispatch now
  agree by construction.

Refusals go through one refuseCapabilityInvocation helper: the kernel
log copy is neutralized (IpcLogText.neutralize) because the reason
carries caller-supplied ids, while the response keeps the raw reason
for the authenticated caller. The verdict itself lives in a
capabilityAdmissionRefusal helper, which keeps the single registry
read and invokeCapability inside detekt's ReturnCount limit.

Four files:
- KernelServiceImpl.kt: the admission check and the refusal helper.
- KernelCapabilityAdmissionTest.kt (new): 5 refusal tests ported from
  #1363 to the new seam - unadvertised action, sibling-namespace
  action, respawn-with-fewer-capabilities, evicted-after-failure
  process, granted call dispatched with exactly the admitted pair -
  over the real authenticated transport, with a recording broker that
  is asserted never-consulted on refusals.
- KernelCapabilityRpcTest.kt: the unwired-broker test now registers
  capability("echo") so it pins the wiring refusal one step past
  admission.
- KernelLogForgingTest.kt: a refused invocation's log line cannot
  forge kernel log records.

Supersedes #1363, whose ProcessRegistryCapabilityResolver seam #1153
retired; the portable refusal tests are ported with credit preserved
per fluck-boss's 2026-09-25 13:23 UTC note.

* feat(browser): await quiescent browser calls (#1063)

Host-side BrowserHandle.awaitBrowserCallsQuiescent implementation and tests. Plugin-facing SDK release and boss-plugin-api pin bump tracked in #1726. Co-authored contribution remains with sidharth-vijayan through PR #1063.

* fix(db): the plugin store's name sort sorts by name, and its pages are stable (#1715)

* fix(db): the plugin store's name sort sorts by name, and its pages are stable

search_plugins_internal ordered by one sort_key, and that key had three defects, all
visible to a store client:

1. p_sort_by = 'name' sorted nothing: its key was the constant 0, so every row tied. The
   host sends 'name' for PluginSortOrder.NAME, and the local repository really sorts by
   displayName, so the same choice meant different things by source.
2. Ties were unordered for every sort, and LIMIT/OFFSET over an unstable order lets two
   pages of one listing repeat a plugin or skip one.
3. Every row's JSON was built before the offset: its version, rating, count, tag and
   organisation lookups ran for each row, then all but one page were thrown away.

The function is restated from 20260923133000 with only the ordering and the order of work
changed. Rows are ranked in a CTE by (lower(display_name) for 'name', sort_key DESC,
plugin_id), which is a total order because plugin_id is unique. Only the page is joined
back and projected. The filters, JSON keys, total_count, signature and ACL are unchanged.

search_plugins_order_test.sql uses five plugins whose plugin_id order differs from their
name order. It pins the name order, including on a second page, and that three pages over
five tied rows see each row exactly once. It also pins the unchanged count, the exact JSON
key set, the last and past-the-end pages, the ACL, and the name sort through the anon
wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(db): replace an assertion that could not fail; cover the filters on both copies

Review follow-up on #1715.

- The "same request twice" assertion compared one deterministic query with itself, so it
  would have passed against the pre-fix function too. It now asserts what it reached for:
  page 1 at size 2 is the first two rows of page 1 at size 5, which an unordered tie cannot
  promise.
- The filter block is written twice, once for total_count and once for the page, and no
  test in supabase/tests passed p_tags or p_verified_only. New assertions check a tag
  filter and verified-only on both the page and total_count, so a copy that drops a filter
  from one of them now fails.
- The migration header warns against folding the count into `ranked` as
  count(*) OVER (): on a page past the end `ranked` has no rows, so total_count would read
  0 instead of the number of matches.

plan(15) matches the fifteen assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ipc): build created and renamed paths in the proxy the way the host does (#1553)

FileSystemDataProviderProxy joined paths by hand ("$parent/$name") in
createFile, createFolder and rename, and returned that string to the plugin.
The in-process provider returns File(parent, name).absolutePath, so on Windows
the two disagreed on every call (mixed separators), and on every OS a parent
passed with a trailing separator produced a doubled one. rename of a path with
no parent also sent a bare name to the kernel, where the in-process provider
refuses with "Cannot determine parent directory".

FileSystemCreatedPathsTest drives the real proxy against a fake
FileSystemService: 5 cases, all failing on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cli): boss mcp ledger secrets - which tools received a credential, and when (#1733)

* feat(cli): boss mcp ledger secrets - which tools received a credential, and when

The ledger has recorded `secretRefs` on every governed call since #822, and
`boss mcp ledger tail|search` printed them per record, but nothing could answer
the question an operator asks before rotating a credential after an incident:
which tools and plugins received this secret, how often, and when last.

- `boss mcp ledger secrets` prints one summary per secret the matching calls
  referenced, most recently used first: calls that named it, how many handed it
  to a tool's handler and how many were withheld, the fields named, the tools
  (with counts) and plugins that asked, and the first, last and last-delivered
  times. `--json` gives the same as a machine-readable document.
- `--secret <id>` (any field of that secret) or `--secret <id>.<field>` (that
  field only) and `--provider <id>` filter `tail`, `search` and `secrets`. A
  selector that is not a secret id, or names a field a reference cannot, is
  refused rather than silently matching nothing; ids are matched case-folded, as
  the parser records them.
- "Delivered" is an exhaustive `when` over McpApprovalDisposition
  (`reachedHandler`), so a disposition added later is a compile error here
  rather than silently counted either way. Every approval disposition means the
  handler ran, because an approval withdrawn at the revocation or secret fence
  is recorded as POLICY_DENIED or SECRET_FORBIDDEN instead; CANCELLED_IN_FLIGHT
  had already started its handler, so it counts as delivered.

It reads only what the ledger records - references, never values - so it needs
no vault access and works with BOSS closed, like the other ledger actions. The
summary lives in its own McpLedgerSecrets object so McpLedgerCli keeps the size
its other actions need.

Tests: six in McpLedgerCliTest (delivered and withheld counted apart, one call
naming two fields counted once, a field selector, search by secret, field and
provider, the human report, selector validation, and "no secret" as an answer)
and two through Clikt in BossMcpCliTest (--file/--secret/--json end to end, and
a bad --secret failing with exit 1). docs/CLI.md documents the action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): let ledger --provider match a plugin, and say what the secrets report covers

Plugin-contributed tools record their provider as `<pluginId>::<providerId>`, so
`--provider secret-manager` matched nothing and an audit read "this plugin never received the
credential". A plugin id now matches every provider the plugin registered, and the scoped form
still matches one; the help text and docs say both.

- The human report labels provider ids `providers:` (the JSON key), prints which ledger it read,
  says a use older than the oldest rotated backup is not counted, and points at
  `boss mcp ledger verify`, on an empty report as well as a full one.
- Legacy CANCELLED, which predates the in-flight split, counts as a possible delivery: no record
  carrying a reference should have it, and for a credential audit over-reporting is the safe way
  to be wrong. The KDoc gives the dates that make it unreachable.
- A comment on the dotless-reference default, and the shadowing parameter renamed.

With the fixes reverted, 4 of 24 McpLedgerCliTest tests fail, including
`expected [vault_list, vault_get] but was []` for the plugin id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(logging): redact local decode failures (#1727)

* fix(chromium): cap archive extraction against a zip bomb (#1546)

* cap Chromium archive extraction against a zip bomb

Extraction now refuses an archive with too many entries or too many bytes, counted from what is actually read and named in the entries and never from ZipEntry.size. Directory entries and long paths draw from the same byte budget as file content, so they cannot create directories past it. The limits are parameters of extractWithJava with constant defaults. The macOS ditto path is unchanged.

* count backslash as a path separator in the extraction budget

Extraction runs on Windows, where a backslash in an entry name separates path components. Counting only the forward slash charged a deep backslash path as one component while it created one directory per segment. Both separators are counted now, with tests for the charge and for a deep backslash archive being refused before anything is created. Also corrects the limits comment: entries cost inodes, not file handles.

* Add owner-scoped RPCs for terminal session sharing (#1731)

* feat: add authenticated RPCs for terminal session sharing

* test: define terminal session RPC replacement contract

* feat: add terminal broadcast relay and account settings (#1734)

* feat: add terminal broadcast relay and account settings backend

* fix: harden relay admission accounting and review validation

* Fix relay queue boundaries and restore synchronization before production

* feat: restrict production relay credentials to ticket admission

* fix: return HTTP conflict for stale terminal preference revisions

* test: verify deployed terminal settings and relay admission

* test(git): make shell glob controls deterministic (#1737)

Co-authored-by: Gavin-Yau <2695188238@qq.com>

* fix(workspaces): stop an earlier session's Space set overruling the crash-recovery record (#1713)

* fix(workspaces): keep the Space set as fresh as the crash-recovery record, from one writer

Last_Session_Set.json was written only at a clean shutdown, and restore reads
it in preference to Last_Session.json. So after a session that restored a set,
worked and was then killed, the next launch brought back the clean shutdown
BEFORE it, and the watcher then wrote those older layouts over the only fresh
copy. Separately, every window's layout watcher wrote Last_Session.json, so a
secondary window's layout could replace the primary's recovery record: #19's
symptom by the in-session route, which LastSessionCoordinator only prevents at
shutdown.

LastSessionCoordinator.ownsSessionRecord(windowId) answers who may keep the
recovery files current during the session: the window a shutdown at that
moment would write for (the primary while it is open, else a live window, the
same choice saveOnProcessExit makes), and nobody while a live window is
protecting a refused restore. writeInSessionRecovery is the watcher's write:
the owner writes the record AND the set, built from the same liveSessionSet
expression the shutdown path uses; any other window writes neither. A window
running fewer than two Spaces removes an earlier set, as the coordinator does.

This replaces the earlier delete-on-first-record approach (reviewed on #1713):
there is no second writer of the set, no lock, no latch, and multi-Space crash
recovery survives restored tabs settling their titles instead of being retired
seconds after launch.

InSessionRecoveryTest drives the real coordinator and one WorkspaceManager per
"session" over a shared directory. With writeInSessionRecovery reduced to dev's
behaviour (every window writes the record, nothing refreshes the set) 3 of its
6 tests fail: the stale set wins, a secondary window writes, and a leftover set
outranks the record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): write the in-session recovery pair as one locked, uncancellable unit, set first

The owner's in-session write was two suspending writes, record then set. Closing a window
cancels its watcher before the coordinator hears of the close, so a cancellation between them
left a fresh Last_Session.json beside the stale Last_Session_Set.json that restore prefers: the
bug this change exists to fix, by another door. And the pair ran outside the shutdown write's
claim, so a Cmd+Q landing mid-pair could let the watcher's set land after the hook's, even
re-creating a set the hook had just deleted.

- LastSessionCoordinator.writeInSession: the pair is one blocking call, set first, under a lock
  the shutdown write also takes, and it re-checks the claim inside it, so a watcher arriving
  after the shutdown write adds nothing.
- writeInSessionRecovery runs it on Dispatchers.IO + NonCancellable.
- WorkspaceManager: the watcher's record write is split into a blocking file write and the list
  refresh, which stays on the caller's dispatcher; the suspend set helper is gone.
- Docs: LastSessionSet names the in-session path; ownsSessionRecord says a window closed
  mid-restore latches the same protection.
- Tests: a cancel mid-pair, a shutdown arriving mid-pair, a write after the shutdown write, and
  the refused-restore case now asserts the disk. Restored to the previous head's shape (record
  then set, two hops, no lock), 3 of the 8 fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): keep a failed in-session recovery write from ending the layout watcher

The review's one remaining item, plus the two clauses and the two tests it named.

- LastSessionCoordinator.writeInSession never throws, like claimAndWrite: the set's serialization
  runs outside the file write's own catch, and a throw from it reached the window's unsupervised
  layout watcher and ended it for the window's life, with no log line. It is now logged under
  LogCategory.WORKSPACE and answered false.
- writeInSessionRecovery keeps a set that cannot be built apart from a null set (which removes the
  file), logs it, and writes nothing, rather than letting it escape into the watcher.
- KDoc: the pair is whole because it is one non-suspending call (a cancel only lands at a
  suspension point); NonCancellable is what keeps a cancel before the IO dispatch from dropping
  the last change. writeLastSessionRecordBlocking says the list refresh is skipped when the caller
  was cancelled.
- Tests: a throwing write is answered false and the next write lands; an unbuildable set writes
  nothing; a cancel before the dispatch still records the change (pins NonCancellable); a write
  whose window closed while it waited for the lock is refused (pins the re-check inside it). With
  the four fixes reverted, exactly these four fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(keymap): show recovery notice after corrupt settings reset (#1725)

* fix(keymap): show recovery notice after corrupt settings reset

Record the preserved settings path before windows exist and offer a one-time reveal action after startup. Keep the public corrupt-file helper contract and cover preservation and delivery in regression tests.

Refs #1695

* fix(keymap): retain recovery notices until acknowledged

* fix(keymap): isolate startup helper to avoid JVM class clash

* fix(keymap): show preserved recovery path and remove stale imports

* fix(crash): swallow supabase-kt heartbeat/disconnect race instead of crashing (#1739) (#1740)

* fix(crash): swallow supabase-kt heartbeat/disconnect race instead of crashing (#1739)

supabase-kt 3.8.0 nulls the realtime socket before cancelling the
heartbeat job, so a per-tick sendHeartbeat child already in flight hits
the throwing websocket getter and escapes the library scope uncaught
(upstream #1394, fixed but unreleased by #1395). The heartbeat loop is
a SupervisorJob sibling and keeps ticking, and reconnect rebuilds the
socket, so one missed heartbeat must not pop the fatal crash dialog.

Extend CrashHandler.isIgnorable with the heartbeat-path sibling of the
existing stale-rejoin containment: exact message plus the two-frame
prefix RealtimeImpl.getWebsocket -> RealtimeImpl.sendHeartbeat, which
is unique to the tick child (sendHeartbeat is private upstream).
Ignorable-test fixtures come from the #1739 release-testing trace.

* refactor(crash): extract realtime containments, pin the two-frame boundary

detekt flagged CrashHandler as LargeClass with the new matcher, so both
supabase-kt realtime containments move to CrashIgnorableRealtime.kt,
each rule keeping its evidence comment. In the ignorable test, replace
the redundant app-frame lookalike (already covered by the renamed-
package assertion) with the cases that pin this rule's actual
boundary: an empty stack stays fatal, and an app frame past the
two-frame prefix stays ignorable by design.

* style(crash): blank line between header and first containment

---------

Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>

* fix(deep-link): stop logging Windows's expected APP_OPEN_URI gap as a warning (#1537)

Every Windows launch logged "Desktop.setOpenURIHandler not supported on
Windows" at WARN with an UnsupportedOperationException trace. The JDK
implements APP_OPEN_URI only in the macOS Desktop peer, so the call threw
on every launch, the handler it tried to register never ran, and the
comment above it ("might not work on all Windows versions") named no
version it did work on. boss:// links and file arguments reach the app
through argv instead, which processCommandLineArgs handles at cold start
and for the single-instance forward.

Remove the attempt and keep the registry setup, the one thing the Windows
branch has to do. #437 closed the same gap for the X11 peer by keeping the
call and logging the refusal at DEBUG; on Windows the call has no path to
success at all, so it goes rather than being quietened - the same reason
setupOpenFileHandler returns early on Windows.

A convention test guards the reintroduction: the macOS branch above and
the default branch below both register a handler, so adding it back to the
Windows branch reads as fixing an inconsistency, and the only symptom is a
log line on an OS no runner here uses.

* fix(db): stop a plugin's author setting its verified badge or moving it into another organisation (#1744)

`authenticated` holds table-level INSERT and UPDATE on public.plugins, and the
row policies let an author write their own plugin row. The policies gate the
row, not the columns in it, so an author could:

- set `verified` (the admin-only checkmark, written by the plugin-store admin
  route behind plugins.admin.publish) with a direct UPDATE, or INSERT a new row
  already verified, since the INSERT policy never checks the column;
- change `org_id` on their own plugin to any organisation, including one they
  do not administer: the UPDATE policy's `auth.uid() = author_id OR
  is_org_admin(org_id)` short-circuits on the author clause.

The migration replaces each client role's table-level grant with column-level
grants on every current column except `verified` (INSERT and UPDATE) and
`org_id` (UPDATE only; INSERT stays under the policy's can_publish_org_plugin
check), read from the catalog as 20260918000000 does, and fails closed if the
privilege survives through PUBLIC or an inherited role. The plugin-store edge
function uses the service-role key and is unaffected; nothing in this repo
writes the table as a client.

plugin_trust_columns_test.sql (13 assertions): on unpatched dev 7 fail, with
the verified UPDATE and INSERT and the foreign-org UPDATE all landing. Full
pgTAP suite: 50 files, 1151 tests, pass.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test(db): pin the exact plugins columns closed to clients, and say how the next one is closed (#1747)

The three non-blocking follow-ups fluck-boss left on #1744's merge.

- plugin_trust_columns_test.sql pins the closed set exactly: {verified, org_id} for UPDATE and
  {verified} for INSERT, for authenticated and anon. A column added to public.plugins is not
  client-writable until a migration grants it; now it fails the suite until someone decides
  whether clients may write it, instead of landing closed without anyone noticing. With a
  `featured` column added, the four new assertions fail (`have: {featured,org_id,verified}`).
- The migration header names the "Users with plugins.admin.publish can update any plugin" policy,
  so "the edge function is the one legitimate writer" can be checked against admin tools
  outside this repository.
- The header says the block cannot be copied to protect a third column: it acts only on a
  table-level grant, and none remains after it runs. With per-column grants, a later migration
  revokes the one column directly.

Comments only in the applied migration; its statements are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(spaces): resolve named-save ownership at press time (#1749)

* fix(dashboard): keep recent-file dismissals through startup loads (#1751)

* refactor(dashboard): generalize in-flight list load guard

* fix(dashboard): preserve file dismissals during startup

* test(dashboard): cover file dismissal load races

* fix(auth): redact passkey IDs and narrow route diagnostics (#1760)

* fix(logging): keep private local state out of decoder logs (#1762)

* fix(logging): redact local-state decode failures

* fix(workspaces): keep private layouts out of decode logs

* fix(plugins): replay window registration snapshots on restore (#1356)

* fix(plugins): replay window registration snapshots

* fix(plugin): prepare a registration before dropping its predecessor

register() removed the owner's live entry before running target.prepare;
both current prepares catch Throwable and the rest are identity, so nothing
throws today - but any future fallible prepare would strand the served
registration with nothing published. Preparing first keeps the old entry
serving on a throw, at no cost to the success path.

* test(plugins): preserve registration after snapshot failure

---------

Co-authored-by: Instinct Agent <agent@instinct.com>

* fix(editor): prevent stale navigation target replay (#1405)

* fix(editor): unify navigation target replay ownership

* test(editor): cover navigation delivery contract

* test(editor): isolate navigation IPC coverage by platform

* test(editor): cover runtime serialization and burst capacity

* fix(dashboard): require guarded list mutations (#1764)

* :arrow_up: deps:(deps): bump kotlin from 2.4.10 to 2.4.20 (#654)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin.jvm
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.multiplatform
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.plugin.compose
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.plugin.serialization
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin:kotlin-test-junit
  dependency-version: 2.4.20
  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>

* fix: complete glass settings search after dev integration

* fix: handle recovery write failures and reject ZIP directory payloads

* fix(plugin-store): let organisation members download their organisation's plugins (#1596)

* fix(plugin-store): let organisation members download their organisation's plugins

Both download routes looked the plugin up with get_plugin_with_stats, which
resolves visibility for auth.uid(). The edge function holds a service-role
client, so auth.uid() is NULL and every `org` or `unlisted` plugin came back
as no row. The route answered "Plugin not found" before canInstall() saw the
caller, so no member could install or update their organisation's plugins.

The download lookup now reads the published plugin row directly and applies
no visibility; user_can_install_plugin stays the only gate, as the routes
already intend. Passing the viewer to get_plugin_with_stats_for_viewer would
fix `org` but not `unlisted`, because viewing is deliberately narrower than
installing. A refused caller still gets the same 404 as a missing plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(plugin-store): cover API key download visibility

* refactor(dialogs): split clone progress content

* fix(plugin-store): mark download responses private; close review test gaps

- Cache-Control: private, no-store on both download 200s: the body can
  describe an org plugin and carries a signed URL on a caller-less path.
- Correct the getPluginForDownload KDoc: published = true is the only
  publication guard, since the install predicate admits authors/admins.
- Tests: per-lookup filter recording, version-sensitive version stub
  (unknown version 404s), required-permission 403, lookup error 500
  without echoing the DB message, Cache-Control assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

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

* Stop a sleeping laptop from emptying the window overnight (v2, supersedes #1207) (#1771)

* Stop a sleeping laptop from emptying the window overnight

Two defects, in series, that between them lose a night's tabs.

PluginWatchdog judged heartbeat age on the raw wall clock. Stall
suppression is deliberately bounded (maxSkippedChecks), and a laptop
sleeping in quarter-hour bursts reaches that bound; the tick past it
then failed every plugin on an age that was almost entirely the host's
sleep, spending restart budget until a plugin was disabled and its open
tabs went with it. The watchdog now banks the suspension it measures and
subtracts it from heartbeat age.

The credit is cleared by a FULLY AWAKE tick carrying a beat. An anchor
on "newer than the tick start" is unsound: the last beat before a lid
closes lands inside the same tick that spans the suspend, so that test
discards the credit for the very sleep being measured. Only suspension
is credited, never frozen-while-awake overrun.

The credit is also spent by AWAKE time, not held until a beat. Without
that bound a plugin that dies during a sleep - worst case one that beats
once on the resume, so its sleep is counted both in the heartbeat age
and in the credit - buys itself the whole night before anyone asks about
it again. Continuous awake time with no beat now clears the credit once
it passes the unhealthy threshold, so detection is bounded by how long
the host has actually been RUNNING, whatever the wall clock did. The
same bound is what keeps a wall clock stepped forward by NTP, which is
indistinguishable from a suspend by the two-clock test, from delaying a
wedged plugin's restart by more than that.

Second, the layout watcher wrote whatever was on screen, so the moment a
tab-owning plugin was unloaded it put "tabs": [] over a full session -
the write that turned a recoverable window into a lost one.
layoutWatcherWrite now returns a null record for a layout with no tabs,
so the type forces the call site rather than leaving the condition to be
re-implemented there. The in-memory current workspace still tracks the
live layout, because a plugin's Save reads it.

Residual window, accepted: between a deliberate emptying of the window
and the next dispose or clean exit, a SIGKILL or JVM crash now restores
the last non-empty record instead of the empty one. Restoring tabs the
user had closed is a far cheaper failure than silently discarding tabs
they had not.

Also: skipping the write leaves reportUnsaved comparing against the
stale disk copy, so an emptied window reads as unsaved until dispose
writes it. Left alone - Last Session is not a document the user saves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Keep the watchdog's awake bound across sleeps

Review follow-up from #1207.

The awake counter that spends suspension credit was reset by every
suspended tick. A dead plugin on a laptop that wakes for less than the
unhealthy threshold never reached the bound, and in the double-counted
case (a beat on the resume) kept a whole sleep of negative age for
roughly that long in awake time. The counter is now cumulative, reset
by a beat alone.

Each tick adds at most one interval to it, so a freeze right after a
resume beat cannot spend the sleep's credit in one tick and restart a
plugin that is alive.

Also: drop an assertion in LayoutWatcherWriteTest that could never fail,
cover a tab nested two splits deep, make hasAnyTab private, and document
the claim-once and hard-crash residuals on the empty-layout guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(logging): pin the path-embedded protocol-relative leak and the maskUriParams side of #1663 (#1770)

Document the accepted residual path-embedded userinfo leak and pin its expected output in a regression test. Add maskUriParams coverage for the protocol-relative redaction from #1663. This does not fix the residual leak; its tracker remains open.

Refs #1665

* fix(terminal): closeStdin cancellation check, clarify IPC 1.4.0 load gate, tighten tests (#1777)

* fix(terminal): re-check coroutine activity after acquiring input lock in closeStdin

Issue #1677 item 1:
- Add currentCoroutineContext().ensureActive() right after acquireInputLock
  in closeStdin().
- If the caller was cancelled while parked on the input lock behind a slow
  writer, abandoning the call does not proceed to deliver EOF to stdin once
  the lock becomes available.

* docs(ipc): clarify IPC 1.4.0 minIpcVersion load gate semantics

Issue #1677 item 2:
- Clarify that an older host answers CloseInput with UNIMPLEMENTED only if
  the runtime declared minIpcVersion <= host.
- A runtime declaring minIpcVersion: 1.4.0 is refused at spawn time by
  <= 1.3.0 hosts.
- Drop misleading 'Like 1.0.0 below' comparison.

* test(ipc): split CURRENT version pin into dedicated test

Issue #1677 item 3:
- Split CURRENT == 1.4.0 pin out of the minIpcVersion compatibility test
  into a dedicated test current IPC version is 1_4_0.
- Keeps the version pin test cleanly distinct from the compatibility logic test.

* test(terminal): wrap join in withTimeout and add closeStdin cancellation test

Issue #1677 items 1 & 4:
- In TerminalCloseInputTest, wrap unbounded writer.join() in withTimeout(5_000)
  inside the busy test finally block so a hung writer cannot stall CI indefinitely.
- Add comment documenting that 'closing input twice is a no-op' pins the RPC-level
  idempotence contract.
- Add unit test 'cancelling a queued closeStdin abandons closing input' in
  TerminalSendQueueTest asserting that cancelling a coroutine queued on the input lock
  does not close stdin once the lock is acquired.

* refactor(auth): rename createLockFileExclusively and retain atomicWriteText for session key (#1675) (#1773)

Clarify lock sidecar creation by renaming createKeyFileExclusively to createLockFileExclusively and its parameter to lockFile. Retain atomicWriteText for session key publication under the same persistent sidecar lock. Add a desktop regression test for the resulting key and lock-file owner permissions.

The earlier CREATE_NEW final-key write in this branch was reverted before merge; it is not part of the resulting change.

* fix(terminal): release input mutex when acquire is cancelled (#1778) (#1779)

- Catch CancellationException in acquireInputLock and unlock ticket if held before rethrowing
- Update closeStdin KDoc to document post-admission ensureActive semantics
- Guard writer.join() with withTimeoutOrNull in TerminalCloseInputTest
- Add regression test for uncontended acquire cancellation in TerminalSendQueueTest

* fix(windows): preserve localhost authority for genuine UNC share paths (#1645) (#1772)

* fix(windows): preserve localhost authority for genuine UNC share paths (#1645)

* Refactor CodeSourceLocation to chain recoverCatching and tighten tests

* fix(plugin-store): gate direct version reads on finalized rows (#1630) (#1775)

Gate direct plugin_versions reads on finalized rows while retaining the existing plugin-visibility policy. Add pgTAP coverage for public, org, and unlisted versions across anon, outsider, member, author, admin, and service-role access, including pending and half-finalized rows. Handle finalized version UPDATE events in PluginStoreRealtimeService and test the client handler.

Deployment checks remain: audit existing real-sha versions with zero or NULL jar_size before applying this migration, and verify finalized UPDATE delivery through a live realtime client. The UI refresh callback and authenticated org realtime socket are separate follow-ups, not verified by these unit tests.

* fix(plugin-store): refuse over-cap JAR reads on declared size, cover the inflate cancel branch (#1783)

Add pre-read declared-size refusals for remote JAR ranges and EOCD probes, cancel refused response bodies, and catch writable-side decompression rejections.

Tests exercise actual remote inflate-limit cancellation, finite over-cap EOCD/CD refusal streams, and release-asset refusal with zero reads and an observed cancel callback.

Reviewed at 51d72cc against dev 1d381e6 with clean author-only changes, completed exact-head second-layer review, and 18 successful checks including Plugin Store, pgTAP and build-test. Broader remaining bounds work is separate; this change does not claim to close every memory-exhaustion path.

* fix(mcp): harden secret references against casing, plaintext leaks, and drift (#1672) (#1784)

* fix(mcp): harden secret references against casing, plaintext leaks, and drift (#1672)

- McpJsonDepth.kt: Enumerate all four guarded parses on invoke path in KDoc
- SecretReference.kt: Support case-insensitive {{secret:...}} prefix matching
- McpArgumentSanitizer.kt: Expand VALUE regex to avoid premature stop at '}', and redact malformed {{secret:...}} candidates
- McpSecretPrePass.kt: Sanitize literal in malformed secret reference refusal
- McpToolRegistryImpl.kt: Log only exception class name in parseMcpToolArgs failure
- docs/MCP_SECRET_REFERENCES.md: Document case-insensitive prefix handling
- Tests: Add coverage for case-insensitivity, redaction of malformed candidates, and stack safety

* fix(mcp): resolve review blockers on secret reference hardening (#1672)

- Redact unterminated and nested malformed candidates in McpArgumentSanitizer
- Stop echoing raw agent-supplied field name in SecretReferenceParser refusal reason
- Sanitize both scan.literal and scan.reason in McpSecretPrePass
- Use decodeFailure(e) and handle SerializationException in nonObjectArgsError and parseMcpToolArgs
- Allow adjacent valid secret references in sensitiveAssignment
- Expand test coverage with unterminated shapes, adjacent references, and casing fuzzing
- Update KDoc and documentation in MCP_SECRET_REFERENCES.md

* fix(mcp): resolve review blockers on unterminated references and decode logging

- Make malformedSecretReference regex tolerant of unterminated candidates and bound at closing brace
- Allow repeated adjacent valid secret references in sensitiveAssignment lookahead with possessive group
- Report offset and length in SecretReferenceScan.Malformed and omit raw candidate text in refusal descriptor
- Stop echoing unverified fieldPart verbatim in SecretReferenceParser.parseBody
- Sanitize scan.reason in McpSecretPrePass.prepare
- Use decodeFailure(e) for SerializationException in nonObjectArgsError and parseMcpToolArgs
- Add unit tests for unterminated shapes, adjacent valid references, wireName alternation pinning, and small-stack safety
- Update KDocs and docs/MCP_SECRET_REFERENCES.md

* fix(cli-installer): bound Windows PATH update process timeout (#1254) (#1786)

* fix(cli-installer): bound Windows PATH update process timeout (#1254)

- Bound setx process.waitFor with 30s timeout and destroyForcibly on expiration
- Drain stdout and stderr asynchronously on daemon threads to prevent pipe buffer deadlock and capture failure diagnostics
- Return false and log warning with captured streams on timeout or non-zero exit code
- Extract executeSetx to maintain clean separation and obey linter complexity limits
- Add CLIInstallerWindowsPathTest unit and regression tests covering success, failure, timeout, and pre-existing PATH paths

* fix(cli-installer): destroy child on exception and discriminate async drain in tests (#1254)

- Clean up child process and drain threads in executeSetx finally block
- Restore Thread.currentThread().interrupt() on InterruptedException
- Require positive timeoutSeconds in updateWindowsPath and executeSetx
- Move drain thread joins above finished branch for reliable diagnostics
- Make top-level constants private
- Add mutation-discriminating test using FakeProcess stdout blocking until destroyForcibly
- Add interruption and argument validation tests

* fix(cli-installer): add test timeouts, safe interrupt clearing, and log before interrupt restore (#1254)

* fix(workspaces): redact decode failures on CLI workspace load (#1711) (#1788)

* fix(workspaces): redact decode failures on CLI workspace load (#1711)

- In BossAppEventBusEffects, handle SerializationException separately from
  general exceptions when deserializing Space JSON from WorkspaceEventBus.
- Use decodeFailure(e) to log structured offset and exception-type diagnostics
  without leaking the offending Space JSON (tab URLs, project paths, terminal commands)
  into host logs.
- Retain throwable on ordinary I/O exceptions in catch (e: Exception).
- Extract subscribeWorkspaceLoadEvents to enable window-filtered event testing.
- Add listener-level regression tests in WorkspaceDecodePrivacyTest covering:
  * Redaction of private layout data on corrupt Space CLI loads
  * Filtering of events destined for other windows
  * Successful dispatch and zero-warning logging for valid Space loads
  * Retention of throwables on ordinary I/O errors

* fix(workspaces): deterministic subscriber readiness, distinct spacePath key, and cancellation preservation (#1711)

- Expose WorkspaceEventBus.subscriptionCount to allow tests to synchronize
  subscriber registration deterministically before event emission.
- Use 'spacePath' key in log metadata to avoid key collision with decodeFailure's 'path'.
- Rethrow CancellationException in subscribeWorkspaceLoadEvents to preserve coroutine cancellation.
- Replace arbitrary delays in WorkspaceDecodePrivacyTest with CompletableDeferred signals,
  cancelAndJoin lifecycle, and deterministic readiness assertions.

* test(auth): observe and make visible Windows inconclusive path in UserDataStorageMergeTest (#1676) (#1791)

- Invert comment-only empty if branch into assertNonVacuousRace helper

- Log 'inconclusive on this leg' with observed form count when Windows file handle contention blocks atomic writes

- Add unit tests pinning platform non-vacuity behavior for both landed and unlanded writes

- Fixes #1676

* fix(wizard): align stageAndInstall in-place cleanup with refusal premise (#1648) …
kshivang added a commit that referenced this pull request Oct 9, 2026
* fluck.risaboss.com: sign in and open your Fluck web chat (registry + tickets) (#1847)

* fluck.risaboss.com: sign in with BOSS and open your Fluck web chat

Carries #1837 (feat/fluck-web-portal @ ed9dd96) onto dev: the fluck-web edge function,
the fluck-web-alias Worker, migration 20261006120000_fluck_web_instances, the redirect
function's Fluck brand, both magic-link templates, tests and CI wiring. Also cancels the
two upstream bodies the last review found still discarded (post-rotation 401, list 5xx).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fluck-web-alias: send streamed request bodies with duplex: half

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fluck-web: require JSON on otp/session/logout; show a spent-link error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fluck_web limits: per-user instance cap, port 1-65535, P0001 ticket cap

New migration 20261007090000 (20261006120000 is applied on prod, so it is not edited).
fluck-web's endpoint regex follows the new CHECK. pgTAP: fluck_web_limits_test.sql.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fluck-vault: per-install keys, proven rotation, operator-approved issuance, ISO 4217 amounts (replaces #1769) (#1830)

* fix(settings): recover corrupt keymap, zoom and workspace settings files, and lock zoom updates (#938)

Co-authored-by: abhinavlenka <abhinavlenka@users.noreply.github.com>

* fix(plugins): follow a Downloads folder the user moved on Windows

Windows lets the user move Downloads (Explorer, Properties, Location), for
instance to D:\Downloads, but BOSS kept answering %USERPROFILE%\Downloads.

- WindowsDownloadsFolder reads the shell's value for FOLDERID_Downloads with
  reg.exe, parses REG_SZ and REG_EXPAND_SZ, expands %NAME% variables and keeps
  only an absolute path. It needs no native binding, so the host and plugin
  processes read it the same way. Read once per JVM; a missing value, a
  missing reg.exe or a run past 5 s fall back to the old answer. It is only
  consulted when user.home is the Windows profile, so test tasks that
  redirect user.home never read the registry.
- DownloadsDirectory.current() passes it as the known folder, which still
  wins only when it names a real directory.
- FileSystemDataProviderImpl.writeFile/readFile admit the Downloads folder the
  provider hands out, and paths under it, in addition to home. Compared on
  real paths, so .., symlinks and junctions cannot lead into a sibling; a
  Downloads folder at a filesystem root admits nothing; delete stays
  home-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): log an unread Downloads known folder once, and make two reader tests test what they say

Review follow-up for this PR.

- The desktop getDefaultDownloadsDirectory logs once per JVM when the Windows
  known folder could not be read, so a failed registry read is no longer
  indistinguishable from a folder that was never moved. plugin-path-utils stays
  logger-free.
- The commonMain expect KDoc no longer says Windows is always
  %USERPROFILE%\Downloads.
- WindowsDownloadsFolder.current checks os.name itself instead of relying on
  its only caller to.
- "a successful run returns its standard output" ran java -version, which
  prints to stderr, so it passed on empty output; it now runs --version and
  checks the version is in what came back (a planted stdout-dropping mutation
  fails it). The timeout test uses a program that sleeps 60 s against a 1 s
  timeout instead of a 0 ms race.
- RelocatedDownloadsAccessTest's outside-home premise is a @BeforeTest
  assumption, so it gates every case instead of only itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(build): ship plugin-path-utils with the upstream jars plugin-api-ipc needs

plugin-api-ipc calls DownloadsDirectory from plugin-path-utils at runtime
(FileSystemDataProviderProxy), but assembleUpstreamJars shipped only four jars,
none of which contains it. The standalone microkernel runtime would compile
against plugin-api-ipc and then fail with NoClassDefFoundError on the first
getDownloadsDirectory() call.

- assembleUpstreamJars now also ships plugin-path-utils-<ver>.jar, with the
  same dependsOn and rename trick as the other plugin-platform jars, and the
  floor on produced jars is 5, so a missing one fails the task.
- plugin-path-utils gets version 1.0.0, like plugin-api-ipc and
  plugin-api-core, so its jar has a versioned name the runtime can pin.
- The task KDoc and the release.yml comment list the fifth jar.

Before: 4 jars, none containing ai/rever/boss/plugin/pathutils. After: 5 jars,
plugin-path-utils-1.0.0.jar contains DownloadsDirectory.class and
WindowsDownloadsFolder.class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): refuse a Downloads path that cannot be resolved, and make the timeout test time itself

Review follow-up for this PR.

- isInsideDownloads fails closed: an IOException from canonicalFile or
  toRealPath now makes the check return false, so the plugin gets the
  SecurityException refusal instead of a generic I/O failure.
- realPath climbs with Files.exists(NOFOLLOW_LINKS), so a dangling symlink or
  junction inside Downloads is found as the link it is; toRealPath on it fails,
  and the write is refused instead of being checked as a plain name and then
  followed. The KDoc says so.
- The timeout test asserts the elapsed wall time is at least the timeout, so a
  child that fails at once (which also gives null) cannot pass it.
- A comment on the home branch of isReadableAndWritable says it compares
  canonicalFile paths and is knowingly weaker than the Downloads branch.
  Behaviour unchanged.

Seen red first: on the previous code both new RelocatedDownloadsAccessTest
cases fail (IOException: Invalid file path; FileNotFoundException through the
dangling junction), and the timeout test with a child that fails at once
fails after 124 ms, where the old version of the test passed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(auth): pin current passkey attempt and auth arguments (#1691)

* test(auth): pin current passkey attempt and arguments

* test(auth): preserve superseded attempt loading assertion

* fix(performance): clamp the plugin heap before bounding the initial heap with it (#1696)

PerformanceSettings.validated() bounded pluginJvmInitialHeapMb by the stored
pluginJvmHeapMb, not the clamped one. A stored max below 32 made the range
empty, coerceIn threw, and PerformanceSettingsManager's load fell back to
defaults, dropping every performance setting in the file over one bad value.
A stored max above 8192 let the initial heap end up above the clamped max.

Clamp the max heap first and bound the initial heap by that value.

Fixes #1000

* test(db): pin the viewer wrapper's page-size floor, and say LIMIT 0 changed (#1671 follow-up) (#1700)

#1671's re-review found the one clamp term its test left unpinned: GREATEST(..., 1) on
search_plugins_for_viewer's page size. Without it, the null and 101 cases still give 20
and 100, so a viewer-only typo there would ship. One assertion at 0 pins it, mirroring the
search_plugins block.

The page-size-0 case also gets a comment saying the behaviour changed. LIMIT 0 is legal, so
a direct call used to return an empty page with a correct total_count; it now returns one
row, matching get_popular_tags. The note lives in the test rather than in the merged
migration, which is already applied.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test: fail on skipped tests in every module and in the IDE, not only composeApp's Gradle run (#1699)

Review follow-up on #1667, which merged before these landed.

- The discovery-issue guard moves from composeApp's Test tasks to the root allprojects
  block, so every module's Gradle test run fails on a @Test JUnit will not execute.
  Checked by adding a value-returning test to plugin-loader, whose run then failed with
  "must not return a value".
- composeApp repeats it in src/desktopTest/resources/junit-platform.properties, which
  JUnit reads however it is launched, so an IDE run meets it too. Checked with the root
  property removed: the same probe in composeApp still failed the run.
- The build comment says what a failure looks like (discovery aborts for the whole engine,
  and one initializationError names the method) and to relax the property to ERROR rather
  than delete it if a toolchain bump brings an unrelated warning.
- AGENTS.md records the rule beside the composeApp test-home note: declare an
  expression-bodied test `(): Unit =`, or write runBlocking<Unit>.
- McpApprovalGateTest's buffer test waits on pendingList instead of a 50 ms sleep, as its
  deny-all sibling already does. The unknown-surface test now observes the no-op: nothing
  appears, and a real surface survives.

All 29 modules' test tasks with the guard on: 8,414 tests, 0 failed. ktlint and detekt
clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugin-store): the #1508 tidy-up - one ceiling per bound, a real 400 body, paged routes bounded (#1669) (#1702)

All seven items from #1669, the follow-ups to #1508.

1. The popular-tags handler's Number.isInteger guard was dead: the route's schema validates
   before the handler runs, so `limit` is already an integer in 1..100. It is deleted, the
   handler reads `const { limit } = ctx.req.valid("query")`, and the NaN / LIMIT NULL
   rationale moves onto the schema. The handler's comment claimed an invalid limit still
   spent rate-limit budget, and /search's claimed the limit was consumed before parsing;
   both were untrue for the same reason and now say what happens.
2. browse gets a defaultHook, so a request that fails its schema is answered with the
   ErrorResponseSchema { error } every route declares, naming the field, rather than the
   validator's own { success: false, error: <ZodError> }.
3. Each ceiling is defined once in types/schemas.ts: POPULAR_TAGS_LIMIT_MAX,
   CATALOGUE_PAGE_SIZE_MAX, CATALOGUE_PAGE_MAX. The popular-tags query schema moves there as
   PopularTagsQuerySchema.
4. schemas.test.ts imports that schema instead of restating it, so moving the route's cap
   can no longer leave its tests green. It also pins the cap's edge.
5. /list declares its 400. catalogue-paging-bounds.test.ts checks /list?pageSize=999999999
   gets that 400 before the database.
6. Both routes bound `page` (1..10,000) and /search gains .int() on page and pageSize.
   A deeper page is a 400 instead of an empty page from the SQL clamp.
7. The popular-tags recorder keeps the RPC name and asserts get_popular_tags.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test(settings): fail on any settings or state writer that falls back to writeText (#1701)

A regression watch for #1022, which routed thirteen writers through atomicWriteText
(#1011, #1659) with a test for only one of them. Asked for when #1661 was closed as
superseded.

SettingsAtomicReplaceTest has one case per writer. Each saves through the API the app
uses and asserts the file got a new identity: a file rewritten in place keeps its inode,
and one replaced by rename gets a new one.

- The old inode is pinned with a hard link before the measured save, because ext4 hands a
  just-freed number to the next file created and a correct replace could compare equal.
  That happened once on ubuntu in #1661.
- The file is created first when absent, so a writer that loads on a background thread
  reads it rather than writing a default that would hide a reverted save.
- Installed plugins adds its own entry and removes it, leaving the shared installed.json
  as it found it. The HTML and plugin-state stores run on temp files.
- Windows exposes no fileKey, so the cases are skipped there with the reason.

Mutation-checked against a fresh test home: switching each of the thirteen files from
atomicWriteText to writeText fails exactly that file's case, 13 of 13. 1,181 tests across
the settings, plugin, html and filetypes suites pass; ktlint and detekt clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): make the corrupt-file aside helper non-throwing, and pin its collision path (#1704)

Follow-ups from the #938 merge review.

renameAsideCorrupt promised best-effort failure through its return value,
but toPath() throws InvalidPathException for a name the filesystem cannot
represent, and the move and the permission narrowing can throw
SecurityException. The three settings managers call it from recovery paths
their object initializers reach, so an escape failed the whole manager.
Both are now caught and logged, and the call returns false (#1692).

The stamp can be supplied, so the collision test pre-creates the first
aside and checks the retry to -1 keeps both copies, instead of relying on
two moves landing in one millisecond. A second test fills every suffix and
checks that nothing is overwritten (#1693).

The keymap Json comment now says that coercion is not a round trip: the
older build's next write persists the default, so a downgrade still loses
that one field, and a test pins it (#1694).

Fixes #1692
Fixes #1693
Fixes #1694

* fix(plugin-store): every router answers a schema failure with the 400 body it declares (#1705)

#1702 gave `browse` a defaultHook, so a request that fails its schema gets the
ErrorResponseSchema { error } its routes declare. The other five routers (publish,
api-keys, rating, download, admin) still answered with the validator's own
{ success: false, error: <ZodError> }. That body isn't the declared shape, and it hands the
caller the schema's internals. The #1702 review found this gap.

- utils/router.ts: newRouter() builds every plugin-store router with the hook and its
  invalidRequestMessage helper, moved out of browse.ts. It is a factory, not an app-level
  hook, because the hook is bound into a route's validators when router.openapi()
  registers it: a hook on the app in index.ts would never reach a sub-router's routes.
- All six routers use it.
- tests/router-hook.test.ts provokes one schema failure per router that has one (browse,
  rating, admin, api-keys, publish), using a client whose database and auth calls throw.
  Each must return 400 with only an `error` key, naming the field. download's routes take
  only path strings, which nothing fails, so a source scan covers it: no route file may
  construct OpenAPIHono directly, and the six routers must all be built by newRouter.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test: the #1699 and #1701 review follow-ups - buildSrc's guard, clearer settings-test failures (#1708)

Both PRs merged before their non-blocking review points could land.

#1699:
- buildSrc sets junit.platform.discovery.issue.severity.critical=WARNING itself. It is a
  separate build the root allprojects block never reaches, and its tests run in CI's own
  `./gradlew -p buildSrc test` step. All four buildSrc test classes pass with it on.
- The root comment and AGENTS.md say an override belongs in the module's own
  build.gradle.kts, which wins, and that buildSrc carries its own copy.

#1701:
- An unchanged identity is now reported as one of two things, told apart by the file's
  modified time: rewritten in place, or not written at all. The writers log a failed save
  and swallow it, so the old single message sent a reader after a writeText that wasn't
  there. Checked by forcing both.
- The class KDoc records that each measured save is assumed to write unconditionally.
- The default-apps case restores the test-home file and resets the manager, since it is
  the one case that changes shared state.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* build(buildSrc): pin JUnit to the root's version so the discovery-issue guard is live (#1709)

#1708 added junit.platform.discovery.issue.severity.critical=WARNING to buildSrc, and its
review asked whether that line was actually read. It wasn't. buildSrc's only test dependency
was kotlin("test"), which resolves JUnit 5.10.1 / Platform 1.10.1 through Gradle's embedded
Kotlin. The property arrived in Platform 1.13, so nothing read it: a value-returning @Test
in buildSrc was still skipped with the build green.

- buildSrc/settings.gradle.kts imports the root version catalog.
- buildSrc/build.gradle.kts adds the JUnit BOM at libs.versions.junit.jupiter (6.1.3, the
  root's) and the platform launcher. Every JUnit artifact resolves to 6.1.3, and a later bump
  of the root moves buildSrc with it.

Checked both ways with a throwaway `fun probe() = 42` test in buildSrc. On dev it passed
silently (exit 0); with this change it fails with "must not return a value". buildSrc's own
43 tests pass, the root build configures, and ktlint and detekt are clean.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): record escalated shell calls in the ledger, and match format only as a command (#1655) (#1698)

* fix(mcp): record escalated shell calls in the ledger, and match format only as a command

Two of the #1655 leftovers the Linux seat testing still hits.

The ledger row of a CRITICAL shell call that a saved ALLOW did not cover
now carries escalated = true. Under YOLO mode such a call is answered
YOLO_ALLOWED with policyApplied = ASK, exactly like a routine one, so an
audit could not tell a destructive call that ran unattended from any
other. The field is hashed only when set, like secretRefs, so every
existing record keeps its hash; boss mcp ledger tail/search show it.

The registry states the escalation instead of inferring it from
policy != savedPolicy, so the prompt and the ledger row read one answer.

The evaluator matched the text "format " anywhere, which rated
--format json, clang-format and English typed through send_input
CRITICAL and made a saved Always Allow ask again for all of them. format
now counts only as the command word of a segment, or when followed by a
drive such as d:.

Refs #1655

* fix(mcp): address the #1698 review - format with leading switches, and the escalated flag's scope

- format with its switches before the volume (cmd /c format /q e:) rated
  HIGH, because only the token right after format was checked for a
  drive. The whole rest of the segment is scanned now; none of the false
  positives the change removed carries a drive letter.
- The escalated flag means the destructive-shell gate overrode a saved
  ALLOW, not that a call was destructive. Under the default ASK policy a
  destructive call YOLO answers records false. The record KDoc and
  AGENTS.md now say so, and a test pins that case. The KDoc also names
  the secret-policy case and the downgrade caveat for ledger verify.
- authorizeInvocation's escalated parameter loses its default, so a new
  caller has to answer.

* docs(agents): say the discovery-issue guard needs JUnit Platform 1.13, and why buildSrc pins the BOM (#1710)

Requested on #1709. junit.platform.discovery.issue.severity.critical is silently ignored on
JUnit Platform older than 1.13, and a passing run looks the same either way. buildSrc's BOM
pin (#1709) is what makes its guard live, so AGENTS.md now says so, and says how to check
a module with a throwaway value-returning test. That keeps a later "redundant dependency"
cleanup from quietly making the guard inert again.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(logging): log a failed decode by type and path, never with the file's content (#1629, #1695) (#1703)

* fix(logging): log a failed decode by type and path, never with the file's content

kotlinx puts the document it failed on into the exception message, and
even the diagnostic before it can quote a value. Logging those exceptions
as error = e wrote visited URLs with their query strings (recent pages,
#1629), the domains a user zoomed, and keymaps into the log that people
attach to bug reports (#1695).

decodeFailure(e) returns log fields with only the exception type, the
offset and the JSON path, with map keys masked since the zoom settings key
by domain. The recent-pages load and history bootstrap, the three
corrupt-file paths from #938 and the keymap import now log it instead of
the exception. SerializationException is caught ahead of the general
catch, which it would otherwise fall into as an IllegalArgumentException.

Fixes #1629
Refs #1695

* fix(logging): address the #1703 review - read markers from the diagnostic only, never throw

- The offset and path are read from the diagnostic, never from the
  document kotlinx appends after "JSON input:". The offset must lead the
  message, before anything kotlinx quotes, and an out-of-range number is
  dropped with toIntOrNull: a throw here ran inside the caller's catch
  and skipped its recovery.
- The path comes from the LAST marker on the first line, since kotlinx
  appends the genuine one after any value it quotes, and after masking it
  must be pure structure or it is left out. A marker spelled by a value
  or hidden in a key is not logged.
- Map keys are masked by index from the first [' to the last '], so a
  key holding '] or a Unicode line separator cannot end the mask early.
- The rest of the #1629 data and its neighbours: UrlHistoryManager (the
  browser-history.json that recent pages bootstraps from), ResolvedHostsStore,
  RecentFilesManager and SelfHealingSettingsManager's settings load now
  log decodeFailure(e) too.

* fix(logging): stop user_data.json decode failures logging the user's email and id

Review on #1703: UserDataStorage's two corrupt-record arms logged the
decoder's exception, whose message quotes the record - the user's email
and id. Both now log decodeFailure(e), and a test drives a torn record
through both paths and checks no entry carries the exception, the email
or the id.

Also from the review:
- A quoted value with a newline moved the genuine path off the first
  line, leaving the value's own marker last. An odd number of quotes
  before the marker means the line ends inside a value, so the path is
  left out.
- The offset scan is bounded to the start of the diagnostic.
- The KDoc says a non-identifier serial name drops the path too, and the
  space-in-key test comment describes the current masking.

* fix(logging): cover the last two user_data.json decodes

Review on #1703: loadUserData (the startup path, at ERROR) logged the
decoder's exception, and readStoredWizardFlag logged its toString(),
which is the same message by another route. Both quote the record, the
user's email and id. Both now log decodeFailure(e).

The torn-record test now drives all four decodes - loadUserData,
saveUserData's stored-flag read (at DEBUG), and the two wizard paths -
and checks each logs once with no exception, and no entry carries the
email or id.

Also: the odd-quote KDoc says it is a heuristic a value's own
apostrophes can defeat, bounded by the structural check; and the
AGENTS.md bullet forbids the decoder's message by any route and points
at #1711 for the older sites.

* fix(mcp): preserve legacy provider DENY rules (#1682)

* fix(mcp): keep a long argument from crashing the audit record of a call that already ran (#1712)

* fix(mcp): keep a long argument from crashing the audit record of a call that already ran

McpArgumentSanitizer.sanitizeMessage builds the ledger record inside invoke's
finally, after executeAuthorized has run, and the approval request before a
prompt. Its PEM rule repeated three GROUPS greedily - `(?:\s|\\n)*`, the
header-line body and the header lines themselves - and java.util.regex recurses
once per iteration of a greedy group repeat. `-----BEGIN PRIVATE KEY-----`
followed by 4,000 spaces overflows a default thread stack. The schema gate
ignores keys no schema declares, so a call under any ALLOW (saved rule, trusted
plugin, session trust, YOLO) could carry that payload next to its real command:
the tool ran, the ledger lost the row, no dropped-write was counted, and invoke
threw a StackOverflowError to its caller. With a prompt, the approval path
overflowed first and the call died unlogged. sensitiveAssignment's name suffix
`(?:[_-][A-Za-z0-9]+)*` overflows the same way on `password_a_a_a...`.

Every repeated group in the rules is now possessive (`*+`), which the engine
runs as a loop. Nothing that may follow any of those groups can be a character
the group consumed, so no match needs one of them to give anything back, and
the redactions are unchanged: the fuzz, auth-scheme, credential-shape, URI and
secret-reference suites pass as before.

sanitizeMessage also catches StackOverflowError and records
`[OMITTED: could not be sanitized]`: a backstop for a rule added later without
that care, which withholds the value rather than showing it raw.

McpArgumentSanitizerStackSafetyTest states the property: every rule's trigger
(25) followed by every pathological filler (18), at the ledger's 16,384-character
cap and at four times it, sanitizes on a 256 KB stack without throwing and
without reaching the backstop. Against dev it fails 62 cells, all
StackOverflowError, and both registry-level tests (a call run under ALLOW keeps
its ledger row; a prompted call reaches the operator and the ledger) fail with
the StackOverflowError escaping invoke.

AGENTS.md records the rule for anyone adding a sanitizer pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: leave AGENTS.md to the maintainers

The rule this change relies on is stated where it lives, in the KDoc beside
the code; AGENTS.md policy text is not merged from contributor PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kernel): check the requested action against the registered manifest before dispatch (#1716)

invokeCapability checked only that the target process was registered,
never that it advertised the requested action, while listCapabilities
advertised from the same registeredProcesses table - so the set the
kernel dispatched was strictly wider than the set it advertised. Check
the requested action against the registered process's own manifest at
the invokeCapability seam, before the broker is consulted:

- read registeredProcesses[pluginId] once;
- refuse "Process not found: <id>" when absent;
- refuse "Plugin <id> does not advertise capability: <action>" when the
  manifest has no matching action;
- only then hand the request to the broker. Advertise and dispatch now
  agree by construction.

Refusals go through one refuseCapabilityInvocation helper: the kernel
log copy is neutralized (IpcLogText.neutralize) because the reason
carries caller-supplied ids, while the response keeps the raw reason
for the authenticated caller. The verdict itself lives in a
capabilityAdmissionRefusal helper, which keeps the single registry
read and invokeCapability inside detekt's ReturnCount limit.

Four files:
- KernelServiceImpl.kt: the admission check and the refusal helper.
- KernelCapabilityAdmissionTest.kt (new): 5 refusal tests ported from
  #1363 to the new seam - unadvertised action, sibling-namespace
  action, respawn-with-fewer-capabilities, evicted-after-failure
  process, granted call dispatched with exactly the admitted pair -
  over the real authenticated transport, with a recording broker that
  is asserted never-consulted on refusals.
- KernelCapabilityRpcTest.kt: the unwired-broker test now registers
  capability("echo") so it pins the wiring refusal one step past
  admission.
- KernelLogForgingTest.kt: a refused invocation's log line cannot
  forge kernel log records.

Supersedes #1363, whose ProcessRegistryCapabilityResolver seam #1153
retired; the portable refusal tests are ported with credit preserved
per fluck-boss's 2026-09-25 13:23 UTC note.

* feat(browser): await quiescent browser calls (#1063)

Host-side BrowserHandle.awaitBrowserCallsQuiescent implementation and tests. Plugin-facing SDK release and boss-plugin-api pin bump tracked in #1726. Co-authored contribution remains with sidharth-vijayan through PR #1063.

* fix(db): the plugin store's name sort sorts by name, and its pages are stable (#1715)

* fix(db): the plugin store's name sort sorts by name, and its pages are stable

search_plugins_internal ordered by one sort_key, and that key had three defects, all
visible to a store client:

1. p_sort_by = 'name' sorted nothing: its key was the constant 0, so every row tied. The
   host sends 'name' for PluginSortOrder.NAME, and the local repository really sorts by
   displayName, so the same choice meant different things by source.
2. Ties were unordered for every sort, and LIMIT/OFFSET over an unstable order lets two
   pages of one listing repeat a plugin or skip one.
3. Every row's JSON was built before the offset: its version, rating, count, tag and
   organisation lookups ran for each row, then all but one page were thrown away.

The function is restated from 20260923133000 with only the ordering and the order of work
changed. Rows are ranked in a CTE by (lower(display_name) for 'name', sort_key DESC,
plugin_id), which is a total order because plugin_id is unique. Only the page is joined
back and projected. The filters, JSON keys, total_count, signature and ACL are unchanged.

search_plugins_order_test.sql uses five plugins whose plugin_id order differs from their
name order. It pins the name order, including on a second page, and that three pages over
five tied rows see each row exactly once. It also pins the unchanged count, the exact JSON
key set, the last and past-the-end pages, the ACL, and the name sort through the anon
wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(db): replace an assertion that could not fail; cover the filters on both copies

Review follow-up on #1715.

- The "same request twice" assertion compared one deterministic query with itself, so it
  would have passed against the pre-fix function too. It now asserts what it reached for:
  page 1 at size 2 is the first two rows of page 1 at size 5, which an unordered tie cannot
  promise.
- The filter block is written twice, once for total_count and once for the page, and no
  test in supabase/tests passed p_tags or p_verified_only. New assertions check a tag
  filter and verified-only on both the page and total_count, so a copy that drops a filter
  from one of them now fails.
- The migration header warns against folding the count into `ranked` as
  count(*) OVER (): on a page past the end `ranked` has no rows, so total_count would read
  0 instead of the number of matches.

plan(15) matches the fifteen assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ipc): build created and renamed paths in the proxy the way the host does (#1553)

FileSystemDataProviderProxy joined paths by hand ("$parent/$name") in
createFile, createFolder and rename, and returned that string to the plugin.
The in-process provider returns File(parent, name).absolutePath, so on Windows
the two disagreed on every call (mixed separators), and on every OS a parent
passed with a trailing separator produced a doubled one. rename of a path with
no parent also sent a bare name to the kernel, where the in-process provider
refuses with "Cannot determine parent directory".

FileSystemCreatedPathsTest drives the real proxy against a fake
FileSystemService: 5 cases, all failing on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cli): boss mcp ledger secrets - which tools received a credential, and when (#1733)

* feat(cli): boss mcp ledger secrets - which tools received a credential, and when

The ledger has recorded `secretRefs` on every governed call since #822, and
`boss mcp ledger tail|search` printed them per record, but nothing could answer
the question an operator asks before rotating a credential after an incident:
which tools and plugins received this secret, how often, and when last.

- `boss mcp ledger secrets` prints one summary per secret the matching calls
  referenced, most recently used first: calls that named it, how many handed it
  to a tool's handler and how many were withheld, the fields named, the tools
  (with counts) and plugins that asked, and the first, last and last-delivered
  times. `--json` gives the same as a machine-readable document.
- `--secret <id>` (any field of that secret) or `--secret <id>.<field>` (that
  field only) and `--provider <id>` filter `tail`, `search` and `secrets`. A
  selector that is not a secret id, or names a field a reference cannot, is
  refused rather than silently matching nothing; ids are matched case-folded, as
  the parser records them.
- "Delivered" is an exhaustive `when` over McpApprovalDisposition
  (`reachedHandler`), so a disposition added later is a compile error here
  rather than silently counted either way. Every approval disposition means the
  handler ran, because an approval withdrawn at the revocation or secret fence
  is recorded as POLICY_DENIED or SECRET_FORBIDDEN instead; CANCELLED_IN_FLIGHT
  had already started its handler, so it counts as delivered.

It reads only what the ledger records - references, never values - so it needs
no vault access and works with BOSS closed, like the other ledger actions. The
summary lives in its own McpLedgerSecrets object so McpLedgerCli keeps the size
its other actions need.

Tests: six in McpLedgerCliTest (delivered and withheld counted apart, one call
naming two fields counted once, a field selector, search by secret, field and
provider, the human report, selector validation, and "no secret" as an answer)
and two through Clikt in BossMcpCliTest (--file/--secret/--json end to end, and
a bad --secret failing with exit 1). docs/CLI.md documents the action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): let ledger --provider match a plugin, and say what the secrets report covers

Plugin-contributed tools record their provider as `<pluginId>::<providerId>`, so
`--provider secret-manager` matched nothing and an audit read "this plugin never received the
credential". A plugin id now matches every provider the plugin registered, and the scoped form
still matches one; the help text and docs say both.

- The human report labels provider ids `providers:` (the JSON key), prints which ledger it read,
  says a use older than the oldest rotated backup is not counted, and points at
  `boss mcp ledger verify`, on an empty report as well as a full one.
- Legacy CANCELLED, which predates the in-flight split, counts as a possible delivery: no record
  carrying a reference should have it, and for a credential audit over-reporting is the safe way
  to be wrong. The KDoc gives the dates that make it unreachable.
- A comment on the dotless-reference default, and the shadowing parameter renamed.

With the fixes reverted, 4 of 24 McpLedgerCliTest tests fail, including
`expected [vault_list, vault_get] but was []` for the plugin id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(logging): redact local decode failures (#1727)

* fix(chromium): cap archive extraction against a zip bomb (#1546)

* cap Chromium archive extraction against a zip bomb

Extraction now refuses an archive with too many entries or too many bytes, counted from what is actually read and named in the entries and never from ZipEntry.size. Directory entries and long paths draw from the same byte budget as file content, so they cannot create directories past it. The limits are parameters of extractWithJava with constant defaults. The macOS ditto path is unchanged.

* count backslash as a path separator in the extraction budget

Extraction runs on Windows, where a backslash in an entry name separates path components. Counting only the forward slash charged a deep backslash path as one component while it created one directory per segment. Both separators are counted now, with tests for the charge and for a deep backslash archive being refused before anything is created. Also corrects the limits comment: entries cost inodes, not file handles.

* Add owner-scoped RPCs for terminal session sharing (#1731)

* feat: add authenticated RPCs for terminal session sharing

* test: define terminal session RPC replacement contract

* feat: add terminal broadcast relay and account settings (#1734)

* feat: add terminal broadcast relay and account settings backend

* fix: harden relay admission accounting and review validation

* Fix relay queue boundaries and restore synchronization before production

* feat: restrict production relay credentials to ticket admission

* fix: return HTTP conflict for stale terminal preference revisions

* test: verify deployed terminal settings and relay admission

* test(git): make shell glob controls deterministic (#1737)

Co-authored-by: Gavin-Yau <2695188238@qq.com>

* fix(workspaces): stop an earlier session's Space set overruling the crash-recovery record (#1713)

* fix(workspaces): keep the Space set as fresh as the crash-recovery record, from one writer

Last_Session_Set.json was written only at a clean shutdown, and restore reads
it in preference to Last_Session.json. So after a session that restored a set,
worked and was then killed, the next launch brought back the clean shutdown
BEFORE it, and the watcher then wrote those older layouts over the only fresh
copy. Separately, every window's layout watcher wrote Last_Session.json, so a
secondary window's layout could replace the primary's recovery record: #19's
symptom by the in-session route, which LastSessionCoordinator only prevents at
shutdown.

LastSessionCoordinator.ownsSessionRecord(windowId) answers who may keep the
recovery files current during the session: the window a shutdown at that
moment would write for (the primary while it is open, else a live window, the
same choice saveOnProcessExit makes), and nobody while a live window is
protecting a refused restore. writeInSessionRecovery is the watcher's write:
the owner writes the record AND the set, built from the same liveSessionSet
expression the shutdown path uses; any other window writes neither. A window
running fewer than two Spaces removes an earlier set, as the coordinator does.

This replaces the earlier delete-on-first-record approach (reviewed on #1713):
there is no second writer of the set, no lock, no latch, and multi-Space crash
recovery survives restored tabs settling their titles instead of being retired
seconds after launch.

InSessionRecoveryTest drives the real coordinator and one WorkspaceManager per
"session" over a shared directory. With writeInSessionRecovery reduced to dev's
behaviour (every window writes the record, nothing refreshes the set) 3 of its
6 tests fail: the stale set wins, a secondary window writes, and a leftover set
outranks the record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): write the in-session recovery pair as one locked, uncancellable unit, set first

The owner's in-session write was two suspending writes, record then set. Closing a window
cancels its watcher before the coordinator hears of the close, so a cancellation between them
left a fresh Last_Session.json beside the stale Last_Session_Set.json that restore prefers: the
bug this change exists to fix, by another door. And the pair ran outside the shutdown write's
claim, so a Cmd+Q landing mid-pair could let the watcher's set land after the hook's, even
re-creating a set the hook had just deleted.

- LastSessionCoordinator.writeInSession: the pair is one blocking call, set first, under a lock
  the shutdown write also takes, and it re-checks the claim inside it, so a watcher arriving
  after the shutdown write adds nothing.
- writeInSessionRecovery runs it on Dispatchers.IO + NonCancellable.
- WorkspaceManager: the watcher's record write is split into a blocking file write and the list
  refresh, which stays on the caller's dispatcher; the suspend set helper is gone.
- Docs: LastSessionSet names the in-session path; ownsSessionRecord says a window closed
  mid-restore latches the same protection.
- Tests: a cancel mid-pair, a shutdown arriving mid-pair, a write after the shutdown write, and
  the refused-restore case now asserts the disk. Restored to the previous head's shape (record
  then set, two hops, no lock), 3 of the 8 fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspaces): keep a failed in-session recovery write from ending the layout watcher

The review's one remaining item, plus the two clauses and the two tests it named.

- LastSessionCoordinator.writeInSession never throws, like claimAndWrite: the set's serialization
  runs outside the file write's own catch, and a throw from it reached the window's unsupervised
  layout watcher and ended it for the window's life, with no log line. It is now logged under
  LogCategory.WORKSPACE and answered false.
- writeInSessionRecovery keeps a set that cannot be built apart from a null set (which removes the
  file), logs it, and writes nothing, rather than letting it escape into the watcher.
- KDoc: the pair is whole because it is one non-suspending call (a cancel only lands at a
  suspension point); NonCancellable is what keeps a cancel before the IO dispatch from dropping
  the last change. writeLastSessionRecordBlocking says the list refresh is skipped when the caller
  was cancelled.
- Tests: a throwing write is answered false and the next write lands; an unbuildable set writes
  nothing; a cancel before the dispatch still records the change (pins NonCancellable); a write
  whose window closed while it waited for the lock is refused (pins the re-check inside it). With
  the four fixes reverted, exactly these four fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(keymap): show recovery notice after corrupt settings reset (#1725)

* fix(keymap): show recovery notice after corrupt settings reset

Record the preserved settings path before windows exist and offer a one-time reveal action after startup. Keep the public corrupt-file helper contract and cover preservation and delivery in regression tests.

Refs #1695

* fix(keymap): retain recovery notices until acknowledged

* fix(keymap): isolate startup helper to avoid JVM class clash

* fix(keymap): show preserved recovery path and remove stale imports

* fix(crash): swallow supabase-kt heartbeat/disconnect race instead of crashing (#1739) (#1740)

* fix(crash): swallow supabase-kt heartbeat/disconnect race instead of crashing (#1739)

supabase-kt 3.8.0 nulls the realtime socket before cancelling the
heartbeat job, so a per-tick sendHeartbeat child already in flight hits
the throwing websocket getter and escapes the library scope uncaught
(upstream #1394, fixed but unreleased by #1395). The heartbeat loop is
a SupervisorJob sibling and keeps ticking, and reconnect rebuilds the
socket, so one missed heartbeat must not pop the fatal crash dialog.

Extend CrashHandler.isIgnorable with the heartbeat-path sibling of the
existing stale-rejoin containment: exact message plus the two-frame
prefix RealtimeImpl.getWebsocket -> RealtimeImpl.sendHeartbeat, which
is unique to the tick child (sendHeartbeat is private upstream).
Ignorable-test fixtures come from the #1739 release-testing trace.

* refactor(crash): extract realtime containments, pin the two-frame boundary

detekt flagged CrashHandler as LargeClass with the new matcher, so both
supabase-kt realtime containments move to CrashIgnorableRealtime.kt,
each rule keeping its evidence comment. In the ignorable test, replace
the redundant app-frame lookalike (already covered by the renamed-
package assertion) with the cases that pin this rule's actual
boundary: an empty stack stays fatal, and an app frame past the
two-frame prefix stays ignorable by design.

* style(crash): blank line between header and first containment

---------

Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>

* fix(deep-link): stop logging Windows's expected APP_OPEN_URI gap as a warning (#1537)

Every Windows launch logged "Desktop.setOpenURIHandler not supported on
Windows" at WARN with an UnsupportedOperationException trace. The JDK
implements APP_OPEN_URI only in the macOS Desktop peer, so the call threw
on every launch, the handler it tried to register never ran, and the
comment above it ("might not work on all Windows versions") named no
version it did work on. boss:// links and file arguments reach the app
through argv instead, which processCommandLineArgs handles at cold start
and for the single-instance forward.

Remove the attempt and keep the registry setup, the one thing the Windows
branch has to do. #437 closed the same gap for the X11 peer by keeping the
call and logging the refusal at DEBUG; on Windows the call has no path to
success at all, so it goes rather than being quietened - the same reason
setupOpenFileHandler returns early on Windows.

A convention test guards the reintroduction: the macOS branch above and
the default branch below both register a handler, so adding it back to the
Windows branch reads as fixing an inconsistency, and the only symptom is a
log line on an OS no runner here uses.

* fix(db): stop a plugin's author setting its verified badge or moving it into another organisation (#1744)

`authenticated` holds table-level INSERT and UPDATE on public.plugins, and the
row policies let an author write their own plugin row. The policies gate the
row, not the columns in it, so an author could:

- set `verified` (the admin-only checkmark, written by the plugin-store admin
  route behind plugins.admin.publish) with a direct UPDATE, or INSERT a new row
  already verified, since the INSERT policy never checks the column;
- change `org_id` on their own plugin to any organisation, including one they
  do not administer: the UPDATE policy's `auth.uid() = author_id OR
  is_org_admin(org_id)` short-circuits on the author clause.

The migration replaces each client role's table-level grant with column-level
grants on every current column except `verified` (INSERT and UPDATE) and
`org_id` (UPDATE only; INSERT stays under the policy's can_publish_org_plugin
check), read from the catalog as 20260918000000 does, and fails closed if the
privilege survives through PUBLIC or an inherited role. The plugin-store edge
function uses the service-role key and is unaffected; nothing in this repo
writes the table as a client.

plugin_trust_columns_test.sql (13 assertions): on unpatched dev 7 fail, with
the verified UPDATE and INSERT and the foreign-org UPDATE all landing. Full
pgTAP suite: 50 files, 1151 tests, pass.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* test(db): pin the exact plugins columns closed to clients, and say how the next one is closed (#1747)

The three non-blocking follow-ups fluck-boss left on #1744's merge.

- plugin_trust_columns_test.sql pins the closed set exactly: {verified, org_id} for UPDATE and
  {verified} for INSERT, for authenticated and anon. A column added to public.plugins is not
  client-writable until a migration grants it; now it fails the suite until someone decides
  whether clients may write it, instead of landing closed without anyone noticing. With a
  `featured` column added, the four new assertions fail (`have: {featured,org_id,verified}`).
- The migration header names the "Users with plugins.admin.publish can update any plugin" policy,
  so "the edge function is the one legitimate writer" can be checked against admin tools
  outside this repository.
- The header says the block cannot be copied to protect a third column: it acts only on a
  table-level grant, and none remains after it runs. With per-column grants, a later migration
  revokes the one column directly.

Comments only in the applied migration; its statements are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(spaces): resolve named-save ownership at press time (#1749)

* fix(dashboard): keep recent-file dismissals through startup loads (#1751)

* refactor(dashboard): generalize in-flight list load guard

* fix(dashboard): preserve file dismissals during startup

* test(dashboard): cover file dismissal load races

* fix(auth): redact passkey IDs and narrow route diagnostics (#1760)

* fix(logging): keep private local state out of decoder logs (#1762)

* fix(logging): redact local-state decode failures

* fix(workspaces): keep private layouts out of decode logs

* fix(plugins): replay window registration snapshots on restore (#1356)

* fix(plugins): replay window registration snapshots

* fix(plugin): prepare a registration before dropping its predecessor

register() removed the owner's live entry before running target.prepare;
both current prepares catch Throwable and the rest are identity, so nothing
throws today - but any future fallible prepare would strand the served
registration with nothing published. Preparing first keeps the old entry
serving on a throw, at no cost to the success path.

* test(plugins): preserve registration after snapshot failure

---------

Co-authored-by: Instinct Agent <agent@instinct.com>

* fix(editor): prevent stale navigation target replay (#1405)

* fix(editor): unify navigation target replay ownership

* test(editor): cover navigation delivery contract

* test(editor): isolate navigation IPC coverage by platform

* test(editor): cover runtime serialization and burst capacity

* fix(dashboard): require guarded list mutations (#1764)

* :arrow_up: deps:(deps): bump kotlin from 2.4.10 to 2.4.20 (#654)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin.jvm
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.multiplatform
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.plugin.compose
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.plugin.serialization
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin:kotlin-test-junit
  dependency-version: 2.4.20
  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>

* fix: complete glass settings search after dev integration

* fix: handle recovery write failures and reject ZIP directory payloads

* fix(plugin-store): let organisation members download their organisation's plugins (#1596)

* fix(plugin-store): let organisation members download their organisation's plugins

Both download routes looked the plugin up with get_plugin_with_stats, which
resolves visibility for auth.uid(). The edge function holds a service-role
client, so auth.uid() is NULL and every `org` or `unlisted` plugin came back
as no row. The route answered "Plugin not found" before canInstall() saw the
caller, so no member could install or update their organisation's plugins.

The download lookup now reads the published plugin row directly and applies
no visibility; user_can_install_plugin stays the only gate, as the routes
already intend. Passing the viewer to get_plugin_with_stats_for_viewer would
fix `org` but not `unlisted`, because viewing is deliberately narrower than
installing. A refused caller still gets the same 404 as a missing plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(plugin-store): cover API key download visibility

* refactor(dialogs): split clone progress content

* fix(plugin-store): mark download responses private; close review test gaps

- Cache-Control: private, no-store on both download 200s: the body can
  describe an org plugin and carries a signed URL on a caller-less path.
- Correct the getPluginForDownload KDoc: published = true is the only
  publication guard, since the install predicate admits authors/admins.
- Tests: per-lookup filter recording, version-sensitive version stub
  (unknown version 404s), required-permission 403, lookup error 500
  without echoing the DB message, Cache-Control assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

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

* Stop a sleeping laptop from emptying the window overnight (v2, supersedes #1207) (#1771)

* Stop a sleeping laptop from emptying the window overnight

Two defects, in series, that between them lose a night's tabs.

PluginWatchdog judged heartbeat age on the raw wall clock. Stall
suppression is deliberately bounded (maxSkippedChecks), and a laptop
sleeping in quarter-hour bursts reaches that bound; the tick past it
then failed every plugin on an age that was almost entirely the host's
sleep, spending restart budget until a plugin was disabled and its open
tabs went with it. The watchdog now banks the suspension it measures and
subtracts it from heartbeat age.

The credit is cleared by a FULLY AWAKE tick carrying a beat. An anchor
on "newer than the tick start" is unsound: the last beat before a lid
closes lands inside the same tick that spans the suspend, so that test
discards the credit for the very sleep being measured. Only suspension
is credited, never frozen-while-awake overrun.

The credit is also spent by AWAKE time, not held until a beat. Without
that bound a plugin that dies during a sleep - worst case one that beats
once on the resume, so its sleep is counted both in the heartbeat age
and in the credit - buys itself the whole night before anyone asks about
it again. Continuous awake time with no beat now clears the credit once
it passes the unhealthy threshold, so detection is bounded by how long
the host has actually been RUNNING, whatever the wall clock did. The
same bound is what keeps a wall clock stepped forward by NTP, which is
indistinguishable from a suspend by the two-clock test, from delaying a
wedged plugin's restart by more than that.

Second, the layout watcher wrote whatever was on screen, so the moment a
tab-owning plugin was unloaded it put "tabs": [] over a full session -
the write that turned a recoverable window into a lost one.
layoutWatcherWrite now returns a null record for a layout with no tabs,
so the type forces the call site rather than leaving the condition to be
re-implemented there. The in-memory current workspace still tracks the
live layout, because a plugin's Save reads it.

Residual window, accepted: between a deliberate emptying of the window
and the next dispose or clean exit, a SIGKILL or JVM crash now restores
the last non-empty record instead of the empty one. Restoring tabs the
user had closed is a far cheaper failure than silently discarding tabs
they had not.

Also: skipping the write leaves reportUnsaved comparing against the
stale disk copy, so an emptied window reads as unsaved until dispose
writes it. Left alone - Last Session is not a document the user saves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Keep the watchdog's awake bound across sleeps

Review follow-up from #1207.

The awake counter that spends suspension credit was reset by every
suspended tick. A dead plugin on a laptop that wakes for less than the
unhealthy threshold never reached the bound, and in the double-counted
case (a beat on the resume) kept a whole sleep of negative age for
roughly that long in awake time. The counter is now cumulative, reset
by a beat alone.

Each tick adds at most one interval to it, so a freeze right after a
resume beat cannot spend the sleep's credit in one tick and restart a
plugin that is alive.

Also: drop an assertion in LayoutWatcherWriteTest that could never fail,
cover a tab nested two splits deep, make hasAnyTab private, and document
the claim-once and hard-crash residuals on the empty-layout guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(logging): pin the path-embedded protocol-relative leak and the maskUriParams side of #1663 (#1770)

Document the accepted residual path-embedded userinfo leak and pin its expected output in a regression test. Add maskUriParams coverage for the protocol-relative redaction from #1663. This does not fix the residual leak; its tracker remains open.

Refs #1665

* fix(terminal): closeStdin cancellation check, clarify IPC 1.4.0 load gate, tighten tests (#1777)

* fix(terminal): re-check coroutine activity after acquiring input lock in closeStdin

Issue #1677 item 1:
- Add currentCoroutineContext().ensureActive() right after acquireInputLock
  in closeStdin().
- If the caller was cancelled while parked on the input lock behind a slow
  writer, abandoning the call does not proceed to deliver EOF to stdin once
  the lock becomes available.

* docs(ipc): clarify IPC 1.4.0 minIpcVersion load gate semantics

Issue #1677 item 2:
- Clarify that an older host answers CloseInput with UNIMPLEMENTED only if
  the runtime declared minIpcVersion <= host.
- A runtime declaring minIpcVersion: 1.4.0 is refused at spawn time by
  <= 1.3.0 hosts.
- Drop misleading 'Like 1.0.0 below' comparison.

* test(ipc): split CURRENT version pin into dedicated test

Issue #1677 item 3:
- Split CURRENT == 1.4.0 pin out of the minIpcVersion compatibility test
  into a dedicated test current IPC version is 1_4_0.
- Keeps the version pin test cleanly distinct from the compatibility logic test.

* test(terminal): wrap join in withTimeout and add closeStdin cancellation test

Issue #1677 items 1 & 4:
- In TerminalCloseInputTest, wrap unbounded writer.join() in withTimeout(5_000)
  inside the busy test finally block so a hung writer cannot stall CI indefinitely.
- Add comment documenting that 'closing input twice is a no-op' pins the RPC-level
  idempotence contract.
- Add unit test 'cancelling a queued closeStdin abandons closing input' in
  TerminalSendQueueTest asserting that cancelling a coroutine queued on the input lock
  does not close stdin once the lock is acquired.

* refactor(auth): rename createLockFileExclusively and retain atomicWriteText for session key (#1675) (#1773)

Clarify lock sidecar creation by renaming createKeyFileExclusively to createLockFileExclusively and its parameter to lockFile. Retain atomicWriteText for session key publication under the same persistent sidecar lock. Add a desktop regression test for the resulting key and lock-file owner permissions.

The earlier CREATE_NEW final-key write in this branch was reverted before merge; it is not part of the resulting change.

* fix(terminal): release input mutex when acquire is cancelled (#1778) (#1779)

- Catch CancellationException in acquireInputLock and unlock ticket if held before rethrowing
- Update closeStdin KDoc to document post-admission ensureActive semantics
- Guard writer.join() with withTimeoutOrNull in TerminalCloseInputTest
- Add regression test for uncontended acquire cancellation in TerminalSendQueueTest

* fix(windows): preserve localhost authority for genuine UNC share paths (#1645) (#1772)

* fix(windows): preserve localhost authority for genuine UNC share paths (#1645)

* Refactor CodeSourceLocation to chain recoverCatching and tighten tests

* fix(plugin-store): gate direct version reads on finalized rows (#1630) (#1775)

Gate direct plugin_versions reads on finalized rows while retaining the existing plugin-visibility policy. Add pgTAP coverage for public, org, and unlisted versions across anon, outsider, member, author, admin, and service-role access, including pending and half-finalized rows. Handle finalized version UPDATE events in PluginStoreRealtimeService and test the client handler.

Deployment checks remain: audit existing real-sha versions with zero or NULL jar_size before applying this migration, and verify finalized UPDATE delivery through a live realtime client. The UI refresh callback and authenticated org realtime socket are separate follow-ups, not verified by these unit tests.

* fix(plugin-store): refuse over-cap JAR reads on declared size, cover the inflate cancel branch (#1783)

Add pre-read declared-size refusals for remote JAR ranges and EOCD probes, cancel refused response bodies, and catch writable-side decompression rejections.

Tests exercise actual remote inflate-limit cancellation, finite over-cap EOCD/CD refusal streams, and release-asset refusal with zero reads and an observed cancel callback.

Reviewed at 51d72cc against dev 1d381e6 with clean author-only changes, completed exact-head second-layer review, and 18 successful checks including Plugin Store, pgTAP and build-test. Broader remaining bounds work is separate; this change does not claim to close every memory-exhaustion path.

* fix(mcp): harden secret references against casing, plaintext leaks, and drift (#1672) (#1784)

* fix(mcp): harden secret references against casing, plaintext leaks, and drift (#1672)

- McpJsonDepth.kt: Enumerate all four guarded parses on invoke path in KDoc
- SecretReference.kt: Support case-insensitive {{secret:...}} prefix matching
- McpArgumentSanitizer.kt: Expand VALUE regex to avoid premature stop at '}', and redact malformed {{secret:...}} candidates
- McpSecretPrePass.kt: Sanitize literal in malformed secret reference refusal
- McpToolRegistryImpl.kt: Log only exception class name in parseMcpToolArgs failure
- docs/MCP_SECRET_REFERENCES.md: Document case-insensitive prefix handling
- Tests: Add coverage for case-insensitivity, redaction of malformed candidates, and stack safety

* fix(mcp): resolve review blockers on secret reference hardening (#1672)

- Redact unterminated and nested malformed candidates in McpArgumentSanitizer
- Stop echoing raw agent-supplied field name in SecretReferenceParser refusal reason
- Sanitize both scan.literal and scan.reason in McpSecretPrePass
- Use decodeFailure(e) and handle SerializationException in nonObjectArgsError and parseMcpToolArgs
- Allow adjacent valid secret references in sensitiveAssignment
- Expand test coverage with unterminated shapes, adjacent references, and casing fuzzing
- Update KDoc and documentation in MCP_SECRET_REFERENCES.md

* fix(mcp): resolve review blockers on unterminated references and decode logging

- Make malformedSecretReference regex tolerant of unterminated candidates and bound at closing brace
- Allow repeated adjacent valid secret references in sensitiveAssignment lookahead with possessive group
- Report offset and length in SecretReferenceScan.Malformed and omit raw candidate text in refusal descriptor
- Stop echoing unverified fieldPart verbatim in SecretReferenceParser.parseBody
- Sanitize scan.reason in McpSecretPrePass.prepare
- Use decodeFailure(e) for SerializationException in nonObjectArgsError and parseMcpToolArgs
- Add unit tests for unterminated shapes, adjacent valid references, wireName alternation pinning, and small-stack safety
- Update KDocs and docs/MCP_SECRET_REFERENCES.md

* fix(cli-installer): bound Windows PATH update process timeout (#1254) (#1786)

* fix(cli-installer): bound Windows PATH update process timeout (#1254)

- Bound setx process.waitFor with 30s timeout and destroyForcibly on expiration
- Drain stdout and stderr asynchronously on daemon threads to prevent pipe buffer deadlock and capture failure diagnostics
- Return false and log warning with captured streams on timeout or non-zero exit code
- Extract executeSetx to maintain clean separation and obey linter complexity limits
- Add CLIInstallerWindowsPathTest unit and regression tests covering success, failure, timeout, and pre-existing PATH paths

* fix(cli-installer): destroy child on exception and discriminate async drain in tests (#1254)

- Clean up child process and drain threads in executeSetx finally block
- Restore Thread.currentThread().interrupt() on InterruptedException
- Require positive timeoutSeconds in updateWindowsPath and executeSetx
- Move drain thread joins above finished branch for reliable diagnostics
- Make top-level constants private
- Add mutation-discriminating test using FakeProcess stdout blocking until destroyForcibly
- Add interruption and argument validation tests

* fix(cli-installer): add test timeouts, safe interrupt clearing, and log before interrupt restore (#1254)

* fix(workspaces): redact decode failures on CLI workspace load (#1711) (#1788)

* fix(workspaces): redact decode failures on CLI workspace load (#1711)

- In BossAppEventBusEffects, handle SerializationException separately from
  general exceptions when deserializing Space JSON from WorkspaceEventBus.
- Use decodeFailure(e) to log structured offset and exception-type diagnostics
  without leaking the offending Space JSON (tab URLs, project paths, terminal commands)
  into host logs.
- Retain throwable on ordinary I/O exceptions in catch (e: Exception).
- Extract subscribeWorkspaceLoadEvents to enable window-filtered event testing.
- Add listener-level regression tests in WorkspaceDecodePrivacyTest covering:
  * Redaction of private layout data on corrupt Space CLI loads
  * Filtering of events destined …
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.

2 participants