Skip to content

Integrate reviewed navigation, kernel security, MCP and search fixes - #695

Merged
kshivang merged 21 commits into
mainfrom
dev
Sep 15, 2026
Merged

kshivang merged 21 commits into
mainfrom
dev

Conversation

@kshivang

Copy link
Copy Markdown
Contributor

Integrates the reviewed dev changes since #661: trackpad release/scroll ownership (#650), identity enforcement for run configuration/project data/split view (#634, #666, #672), fail-soft secret listing (#668), cancellable clone execution (#660), proactive MCP policy and activity history (#636, #662), and Spotlight project/session isolation (#593).

Dev is rebased onto latest main d7540e4, retaining the released exact-frame browser/editor routing and API 1.0.93 work. Original contributor attribution is retained in the source PRs and integration commits.

Validation: individual PR review/test evidence is recorded in their PRs and the local review inventory. Rebase completed without conflicts and git diff --check passes. Fresh integration CI and manual debug validation are required; prior individual PR results do not establish that the entire new integration has passed.

Manual focus: trackpad navigation release/cancellation; Spotlight isolation across projects/windows and close/reopen; MCP activity viewer and proactive policy; stalled clone cancellation/retry; plugin run/project/split operations. #668 includes a Supabase listing migration; no live deployment is performed by this PR preparation.

shivanshu-risa and others added 11 commits September 14, 2026 19:43
…on bridge (#634)

* fix(overlays): honor RTL and fractional placement (#477)

* Stop anon reaching schema public by inheritance, and scope identity to real orgs

The BOSS Supabase anon key is compiled into this repo, which is public. Anything
`anon` can execute is therefore executable by anyone. Verified on 2026-09-08:

  * get_encryption_key() returned the Vault master encryption key, unauthenticated.
    decrypt_text() was anon-callable beside it, making a decryption oracle over
    everything encrypt_text protects - user secrets and the QBO/Google broker
    credentials.
  * find_user_by_email() confirmed any address and returned its uuid.
  * list_shareable_recipients() returned 152 users WITH full email addresses to
    any self-registered account, and 82 @risalabs.ai addresses on a search.
  * arcade_leaderboard() and arcade_bs_standings() published the roster of
    everyone who had opened the Arcade, unauthenticated.

None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on
every new function, PUBLIC includes anon, and this project's default privileges
add anon on top - so a SECURITY DEFINER function is internet-callable from the
moment it is created. Both revokes are traps in mirror image: `from public`
leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and
ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses
the entry and new functions still get `=X`).

  20260908000000  event trigger: revoke PUBLIC+anon on every function created in
                  schema public. An explicit `grant ... to anon` after the create
                  still wins - anon access must be stated, never inherited.
  20260908010000  org_visible_users / org_is_vetted / user_display_name: ONE
                  definition of "may this account learn who that account is",
                  shared by the Arcade and poker rather than copied. Vetted means
                  a human approved the join, which excludes the catch-all `boss`
                  org every account joins on signup (153 members, 20 domains).
  20260908020000  route list_shareable_recipients through both. It was already
                  org-scoped, which is why it looked right; it accepted ANY
                  shared org.
  20260908030000  one-time sweep of the ~30 functions that already had the
                  inherited grant. The RBAC mutators fail closed
                  (is_user_admin(NULL) is false), so this is defence in depth,
                  not a patched escalation.

The rule is a SET, not a per-target predicate: as a per-row qual it is pushed
below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score
rows for 45 players, against 37ms.

supabase/audit/identity_disclosure_audit.sql is the standing check, because "we
fixed the leak" is not a durable claim. All four checks report HEALTHY.

Left deliberately anon-callable, each documented where it is kept: the plugin
store browse API, the three helpers called from RLS policies on anon-readable
tables, and custom_access_token_hook. Verified after the sweep that anonymous
plugin-store browse and signed-in RBAC reads both still work.

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

* Add the master-key rotation procedure, and rotate

get_encryption_key() was anon-callable, so the Vault master_encryption_key was
retrievable by anyone holding the project anon key - which ships compiled into
this public repo. Revoking access does not un-disclose a key, so it was rotated
on 2026-09-09 and the 184 rows encrypted under it re-encrypted.

Scope of the original exposure, stated precisely because the capability was
worse than the reach: decrypt_text() was anon-callable beside it, so an attacker
holding ciphertext from ANY channel - a backup, an export, a screenshot - could
decrypt it through the API without even needing the key. But no bulk read path
to that ciphertext existed: qbo_token_state and google_token_state have no
client grants at all, and all 178 secrets rows have org_id NULL, so the secrets
policy reduces to owner-only. This was a serious latent vulnerability - one
org-shared secret, one RLS slip, or anyone with backup access would have had
plaintext - rather than a confirmed bulk disclosure.

The script is kept because rotation is not a one-off. It is atomic (one DO
block), self-verifying (md5 of every row's plaintext captured before, re-derived
after through decrypt_text so the check covers both the swap and the data), and
a mismatch or row-count drift raises and rolls back, so a broken rotation cannot
commit. Verified after the run: the live key differs from the disclosed one,
184/184 rows decrypt under it, and the disclosed key opens 0 rows.

The old key is retained in the Vault under an explicit name. Do NOT delete it
while pre-rotation backups exist - they are encrypted under it.

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

* fix(db): close signed-in crypto access and verify visibility boundaries

* test(db): exercise repeated key rotation and exact anonymous ACLs

* test(db): grant fixture role membership on Supabase Postgres

* test(db): inline rotation source for isolated pgTAP mounts

* test(db): keep generated rotation SQL out of source control

* fix(db): reserve identity-taking store mutators for the edge service

* fix(db): serialize rotations without requiring direct Vault writes

* fix(db): address review gaps in system-org and rotation coverage

* test(db): verify core secrets across hex and broker-free rotations

* test(db): execute standing audit and missing-signature regression

* fix(db): enforce revoke postconditions and harden rotation verification

* test(db): enable recovery metadata in the authenticated fixture

* test(db): use the schema-supported authenticator type

* Address remaining database review quality findings

* Fix invoker view audit boolean parsing and test timestamp bounds

* Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift

Addresses the handoff's database-integration item and the substantiated findings
from the current-head review. Everything below was executed against the PR's own
Supabase preview branch, not asserted from reading.

**The blocker: rotation refused to run once #417 landed.** The guard was a
blanket "TOTP is installed, refuse everything". It is now an adapter. The stored
form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing
differs - so the column map gained an envelope prefix and rotates like the rest
once the prefix is stripped and re-applied. Three things this needed that reading
the diff would not have shown:

  * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a
    storage envelope'), so the re-encryption update is rejected outright. The
    reviewed trigger is now disabled for the update and restored after, inside
    the same transaction.
  * safe_decrypt_recovery_codes returns jsonb, not text, so verification through
    the real read path needed a cast. Both fingerprints now go through each
    column's OWN application read path rather than decrypt_text, which also
    removes the step-1/step-5 asymmetry raised as U5.
  * a step 0 pre-check reports rows that are already unreadable, instead of
    letting a safe_decrypt_* NULL surface at the end as "a row was missed".

The rejection contract is preserved, not removed: an unknown envelope version,
an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped
table each still refuse. All three are now regression-tested, along with the
recovery-code column that was previously never exercised (its fixture was
vacuous - create_secret writes no secret_metadata row without p_twofa_enabled).
Verified: three consecutive rotations, TOTP/recovery/password all intact, v1
envelope preserved, trigger re-enabled and still enforcing its own contract.

**CREATE EXTENSION was impossible.** The fail-closed guard aborted the first
routine of any extension installed into public: `create extension pgtap` failed
with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces
operators to disable it in order to install an extension is a guard that ends up
disabled. Extension-owned routines are now exempted with a warning, and the
exposure stays visible as advisory CHECK 1x rather than vanishing - it reports
pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers
are called from column DEFAULTs and CHECK constraints, which are evaluated with
the DML role's privileges. No extension owns a function in public on a clean
database, so no deployed behaviour changes.

**The repo was weaker than production.** find_user_by_email, get_session_status
and update_api_key_last_used were locked down on the live project on 2026-09-08
but never captured as a migration, so a fresh deploy re-opened them.
find_user_by_email is a user-enumeration oracle over auth.users and signup is
open. The standing audit caught this itself on the preview - CHECK 2 named it -
which is the drift that check exists for. Both remaining callers use
SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps
EXECUTE and neither path changes.

Also: the guard no longer hard-fails on a database without the Supabase roles (a
plain restore), and the vault fixtures no longer assume the key is absent (U3) -
both suites failed outright on any database that already had one. Retained-key
assertions measure this run's delta instead of an absolute count.

Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all
clean; the standing audit reports no failing gated check.

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

* Exercise the anonymous read path, not just the grant

The keep-list assertions only checked has_function_privilege for the seven
allowlisted signatures. That proves the grant, not the path: an RLS policy
expression is evaluated as the QUERYING role, so revoking a policy helper turns
an anonymous SELECT into 'permission denied for function ...' rather than an
empty result, and no privilege assertion can see it.

The seeding is the part that matters. A policy expression runs PER ROW, so on
an empty table it is never evaluated - and plugins and user_roles are both empty
on a fresh database, which made the first version of this probe pass no matter
what had been revoked. Caught by trying to make it fail.

Sensitivity is stated from measurement, not assumption: with a row present,
revoking can_view_plugin_row from anon does fail the probe and revoking
authorize does not, because permissive policies are ORed and short-circuit. A
helper in a policy that never has to be evaluated cannot be detected this way by
any test, so the comment says so rather than implying broader coverage.

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

* Make the gated audit checks and the test generator provable

Both halves of this are the same failure: a suite that is green while proving
nothing. I hit that twice writing the previous commits - a recovery-code fixture
over a secret_metadata row create_secret never wrote, and an anonymous-read
probe over an empty table whose RLS policy therefore never evaluated - so these
are not hypothetical.

Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY,
while every advisory check already had a fixture-based detection test. An
inverted `not exists`, a wrong role literal or a typo in the evtenabled test
would have left the gate permanently green. Each is now broken deliberately and
asserted to report it: a routine granted to anon AFTER creation (which is how
one gets past the event trigger), a revoked deliberate anonymous grant, a
disabled event trigger, and a crypto routine exposed to authenticated. They have
teeth by construction - the assertion is that the finding appears, so a broken
audit query fails them.

Finding 8. The generator gained --check, which regenerates in memory and
compares without writing, and orphan removal for a generated suite whose .sql.in
has been renamed or deleted - that file otherwise keeps being executed forever
with stale content. Orphans are identified by a provenance banner rather than a
hard-coded list, so a hand-written suite is never touched; verified that
explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by
making it fire: a hand edit and a planted orphan are both reported by --check,
and generate removes the orphan.

CI now runs generate and --check as their own step, so a preparation failure is
visible in the log instead of being attributed to the test run.

Preview: rotation 16/16, db access audit 15/15, visibility 56/56.

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

* Announce every project change, not only the plugin-initiated ones

Panels could come up empty and stay empty, and the reason was that the
startup restore told no one.

ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject,
which is the path a PLUGIN takes. The two callers that matter most bypass
it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject
directly when it restores the workspace, and BossTopBar's picker goes
through selectProjectInWindow. Neither published anything.

The ordering that produced the empty panel: DefaultPlugin is constructed in
a DisposableEffect keyed on registries and window state, not on
selectedProject, and it kicks off the plugin scan asynchronously while
BossAppScaffold composes and the sidebar builds panels immediately. The
restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON
reads. When those reads are slow the panel is built first, reads the ""
that WindowProjectState seeds itself with, and renders its no-project state.
The restore then lands, silently, and nothing remounts the cached component.

Publishing from the state itself rather than from one caller catches all
three. The bus is MutableSharedFlow(replay = 0), so a publish that never
happens cannot be recovered by a later subscriber - which is why this could
not stay per-caller.

The publish is removed from selectProject rather than added alongside the
collector; keeping both would double-fire on the plugin path.

previousPath is seeded from the current value so the StateFlow's replay of
it is not announced as a change. That seed is "" at startup, and telling
every plugin the project just became "" moments before the real restore
arrives is precisely the clear-yourself signal worth avoiding.

One collector per window: projectDataProvider is `by lazy` on DefaultPlugin,
which is per-window, so this cannot stack.

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

* Publish from the state's own callback, not from a lazily-built provider

The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl`
put the fix behind two `by lazy` initializers. `projectDataProvider` and
`applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only
host path that creates the bus at all. So the collector started only if some plugin
touched both before the workspace JSON reads finished - and "the reads are slow" is
the premise of the bug. Touched after the restore, the collector seeds to the
restored path and announces nothing; never touched, no caller is announced at all.

The announcement now hangs off the `ProjectSelectionCallback` that
`WindowProjectStateRegistry` installs when it builds the window's state, which
`BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is
the sole mutator of the selection and invokes that callback synchronously, so every
caller is covered with no coroutine, no scope and nothing lazy in the path. Being
synchronous also makes `previousProjectPath` a faithful history rather than
best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C.

The two registry entry points carried a copy of the wiring each; they now share one
`newState`, so the announcement cannot be installed on some windows and not others.

Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a
no-op whenever the bus had not been created, so on a build where no installed plugin
had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`,
not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered.
It now creates the bus instead of dropping. `getInstance` also re-checks the registry
on every call rather than only at creation, so an instance that exists while the
registry is empty can no longer strand the host's publisher.

Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and
`DefaultPlugin.dispose()` releases it through a named delegate, alongside
`logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a
`SupervisorJob`, matching `pluginScope`.

Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being
announced, the previous-path chain, the seeding rule, the same-path suppression,
both registry entry points, and the bus being created rather than dropping the event.
Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean.

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

* Round 2: honest bus wording, atomic previousPath, three more tests

Review (1/2) and (2/2).

1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no
   registered publisher means nobody holds the bus, so it has no subscribers and that
   first event still reaches no one. The comment, the test name and the PR body all
   claimed otherwise. Reworded to what it actually buys: the host stops being
   permanently silent while it waits for a plugin to touch `applicationEventBus`.

3. `previousPath` was check-then-act on a plain field, and
   `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to
   Main (verified: `KernelBootstrap` registers it, the bridge calls
   `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read
   and the write are one operation.

5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields
   are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the
   lock is uncontended after the first call and removes the question. The
   bus-before-publisher ordering is now written down, including why a publish landing
   in that window loses nothing.

6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is
   documented as defensive and test-reachable, and defaulted to "".

7. Three tests added:
   - a plugin-initiated selection is announced exactly once (catches both a silent
     regression to zero and an accidentally re-added publish);
   - the registry test now asserts the recent-projects half of the callback ran -
     deleting it from `newState` previously left every test green;
   - `dispose()` stops the recent-projects collector, which is the whole reason
     `projectDataProviderDelegate` became a named lazy.

   `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider
   collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so
   its collector silently never ran. Unconfined also makes the dispose test
   deterministic rather than a sleep.

Smaller: `SupervisorJob` comment describes it as future-proofing (there is one
collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of
disposing the provider is documented on `dispose()`.

Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but
it changes `events()` semantics for every subscriber and every event type on a bus
whose consumers are out of tree. The body now says the race is narrowed rather than
closed, and names the api-side `selectedProject` StateFlow as what closes it.

`:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean.

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

* Round 3: unconditional announcement, ordered publish, drop dead register

Review 3.

2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe"
   paragraph, since this widens *when* a filesystem path reaches every installed
   plugin.

3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only
   the `actual` had been corrected. Fixed, and it now states the part that IS still
   best-effort: replay = 0 means an unsubscribed event is gone either way.

4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw
   there skipped it AND left `previousPath` stale - every later selection reporting a
   previous path one step behind, silently, for the session. Now `try { recents }
   finally { announce }`: order preserved for anyone reading recents off the event,
   the announcement unconditional, the throw still propagating.

5. `getAndSet` closed the read-modify-write but not the publish order, so two
   concurrent selections could emit out of order. `previousPath` and the publish are
   now in one `synchronized` block (the AtomicReference is gone - the lock subsumes
   it). The KDoc says plainly what this still does not settle: `WindowProjectState`
   writes `_selectedProject.value` then calls the callback with no atomicity, so state
   and last event can still disagree. Closing that means locking upstream.

6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value`
   wholesale and could land mid-test, dropping a just-added path - only where
   `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A
   once-per-JVM settle in `@BeforeTest` puts that single file read before any test.
   The test paths stay deliberately non-existent, now with a comment saying why: the
   fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose
   directory is gone, so a real temp directory would leak where these self-heal.

7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`),
   and it overwrote an existing entry - handing back a state with a fresh announcer
   seeded to "" for a window that already had a project. `newState` stays; it is what
   keeps the wiring from drifting.

8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside
   the lock now, and leaving it reads as if a fast path survives.

Also: a test for the actual regression path, `applyWorkspace` announcing the project it
restores, rather than a comment claiming to imitate it.

Answering the grep you could not run: the only `projectChanges()` consumers in
boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign
`_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat
publish as a reload nudge. Recorded in the announcer comment.

Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not
closed"), naming the mirror case and the api-side StateFlow that closes it.

`:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean.

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

* Round 4: split the dispose out, stop tests writing the real ~/.boss

Review 4. Items 1 and 3 were the before-merge ones.

1. Verified: ProjectDataServiceBridge.watchRecentProjects collects
   provider.recentProjects, a StateFlow, so cancelling the provider's scope on
   window close leaves that gRPC stream open and silently frozen for every
   out-of-process plugin. Taking the first option and SPLITTING the dispose out.

   Note this reverses what round 1 asked for, and the justification changed rather
   than the opinion: once the announcement moved to the registry callback,
   ProjectDataProviderImpl went back to being exactly what it was before this PR, so
   the leak is pre-existing and unrelated to the subject. Closing it properly means
   the bridge reading ProjectState directly instead of a per-window provider, which
   is its own change. The reasoning is on the class so it is not re-fixed by accident.

3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict
   real ones from the developer's picker and no cleanup restores them.
   systemProperty("user.home", <build>/test-home) on the Test task. One run with it in
   place shows the existing suite was writing far more than recents to the real home -
   window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json,
   recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all
   land in test-home now. The settle is gone with it: no recent-projects.json in
   test-home means the wholesale reload never happens.

2. publishSystemEvent now refuses the bus != null && systemPublisher == null state
   instead of falling through into a bus that may not be the registry's, with one
   warning rather than one per event. The comment at the fallback is true as written.

4. Both stale comments fixed (one construction path, not two).

5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is
   testable - ProjectState is an object, so a hard-coded call cannot be made to fail.

6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys
   (ordering), what it costs (an inline subscriber runs holding the lock inside
   selectProject), and that the contended path is untested.

7. The serial-execution dependency is recorded in the test class KDoc.

Plus: multi-window isolation, and a test that makes the recents update throw.

One thing this round caused and fixed: removing dispose() left two
ProjectDataProviderImpl collectors alive on the test dispatcher. On a
TestCoroutineScheduler a live coroutine is something every later runTest waits on, and
the git classes timed out at 60s in full-suite runs while passing in isolation.
Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher():
same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a
real argument for the follow-up - without dispose() the provider cannot be constructed
in a test without leaking.

:composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean.

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

* Round 5: reset test-home per run, stop getInstance stealing a publisher

Review 5.

1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting
   there from the previous run. doFirst now deleteRecursively() before mkdirs(), so
   the home is fresh per run rather than merely private. Without it the redirect only
   helped the first run and ProjectState.init's load reintroduced the same race from
   the second. Verified by running the full suite twice back to back.

2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and
   `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest,
   BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests
   both, so getInstance cannot take a publisher away from whoever set it.

3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning -
   partialRegistryWarned is a one-shot process global, so "it warns" is unassertable
   after the first test to trip it. Uses a stub bus so the half-registry state is real.

4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant,
   previousPath advances before the publish), so it is not "fixed" later. The
   lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block.

5. Taken, including the optional ones:
   - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally
     discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching
     Throwable is the contract, and both are rethrown.
   - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main).
     This removes the global setMain AND the round-4 collector leak at the source
     rather than working around them, without reopening the DisposableProvider split.
   - Three more pins: a null window state announces nothing, applyWorkspace
     (restoreProject = false) announces nothing, unregister + getOrCreate restarts the
     chain at "".

Not taken, with reasons: jvmArgumentProviders (the current form works, is
config-cache-serialisable and passed CI on three OSes); widening the user.home redirect
to plugin-path-utils (its test only mkdirs a directory every BOSS user already has).

There is no CHANGELOG in this repo, so the release-notes line for the same-path change
is an action item at release time, not a file change. Flagged in the body.

Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times.
detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in
this round's own code first.

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

* Address project announcement review feedback

* fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488)

* Batch validation: promote latest reviewed dev changes to main (#463)

* Fix #30: Resolve duplicate context menu label collisions

* Cleanup: Remove dead code for #91 and #93

* chore: Sanitize SubmitResult.Error at construction rather than at one render site

* Fix panel component lifecycle disposal

* fixes

* Guard panel lifecycle destruction failures

* fix(logging): sanitize secret RPC failures and crash-report errors

Three related gaps in what gets logged and shown when something fails:

- SecretService.kt had no BossLogger in any of its ten catch blocks (#145).
  When the organisation migration broke all four secret RPCs (#144), the
  only WARN in the log came from the calling plugin - the code that
  actually failed said nothing. Every catch now logs via
  logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized
  failure for both the log and the returned Result rather than sanitizing
  twice. RoleService and RoleCreationService were checked and already log
  correctly - SecretService was the only silent one.

- LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it
  appeared inside a URL - a bare hostname (exactly what
  UnknownHostException.getMessage() produces, i.e. every DNS failure, and
  what a proxy-connect failure looks like) passed through untouched (#109).
  Added a narrow hostname pattern: lowercase-only labels ending in a short
  explicit TLD/`.internal`/`.local` list, with a negative lookahead so a
  package path that happens to end in a real TLD word mid-FQN
  (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException)
  is not mistaken for one - caught by the existing realistic-stack-trace
  test after the first version of the pattern redacted a live Kotlin
  package name out of it.

- CrashReportService.SubmitResult.Error held a raw exception string,
  sanitized at exactly one render call site in CrashReportDialog (#110).
  The constructor is now private; SubmitResult.Error.of(...) is the only
  way to build one, and it sanitizes before the raw string can reach
  .message - a property of the type now, not something every future
  consumer (a copy button, a toast, a log line) has to remember on its own.
  Both construction sites (the service's two catch blocks, and the one in
  CrashReportDialog the issue names directly) now route through it.

Tests: new coverage for the ten now-logging SecretService paths (via
SupabaseWiringTest's existing sanitization-wiring guard, updated to
recognize the "sanitize once into a local, reuse for log and return" shape
SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for
the hostname fix (including the FQN/version-number false-positive guards),
and a new CrashReportServiceTest for the sanitize-at-construction guarantee.

* test(crash): extract construction tests from #404

Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately.

* chore(ipc): clarify advisory menu ID scope and fix formatting

* chore(ui): finish obsolete toolbar comment cleanup

* test(crash): adapt consolidated coverage and guard factory visibility

Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately.

* refactor(crash): consolidate construction sanitization in #311

Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively.

* test(panels): retain sibling lifecycle isolation coverage from #313

Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work.

* fix(panels): complete lifecycle cleanup across failure and window teardown

Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions.

* docs(ui): remove stale disabled top-bar feature list

* test(ipc): protect advisory menu item ID uniqueness

* fix(panels): keep store registration scoped to its window effect

Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged.

* fix(crash): address consolidated sanitization review notes

Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate.

* fix(logging): keep server failure payloads out of secret RPC logs

Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately.

* ci: build PR batches on dev

* Allow Claude diff reviews after approved fork builds (#413)

* fix(browser): preserve newer clipboard copies during plain-text paste (#316)

* fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205)

* fix(browser): make paste-without-formatting's restore identity-based, not text-based

Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers.

* fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount

* fix(browser): blank lines between FakeTransferable overrides per ktlint

* test(browser): retain clipboard restore scenarios from #408

* fix(browser): track clipboard ownership through AWT wrappers

---------

Co-authored-by: rehannayeem0786 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>

* fix(plugins): serialize concurrent first loads per class name (#324)

* fix(plugins): prevent duplicate class definitions during concurrent loads

* fix(plugins): make class loading parallel-safe

* test(plugins): pin concurrent loading lifecycle boundaries

Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes.

---------

Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>
Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412)

* fix: make BrowserHandle JavaScript execution cancellable

the previous synchronous JxBrowser call blocked the Main/EDT thread;
coroutine cancellation could not take effect while that call was blocking;
the implementation now uses JxBrowser’s asynchronous JavaScript callback API;
suspendCancellableCoroutine makes the Kotlin waiting side cancellable;
late callbacks after cancellation are safely ignored;
this does NOT terminate JavaScript already executing inside Chromium.

* fix: add robust native call lifecycle tracking for disposal

* test: verify native operation cleanup on synchronous failure

* fix: linearize browser disposal and pending operation tracking

* fix: resolve detekt violations in browser native tracking

* test: satisfy detekt failure simulation rule

* fix: close native operation disposal race

* fix(browser): drain an in-flight call before closing the browser

Issue #300 reports that executeJavaScript is a synchronous, non-
cancellation-aware native call, and a plugin's own withTimeoutOrNull
around it can only abandon the caller's wait - not the call itself -
which stays running on Main indefinitely and can race a concurrent
handle.dispose(), touching the same native object from two threads at
once.

The severe half of this was already fixed by BoundedBrowserCall: this
class confines every blocking round trip to one dedicated daemon thread
instead of Dispatchers.Main, so a wedged renderer no longer freezes the
app. What was not yet closed is the residual window this issue's core
report is actually about: shutdown() stopped new work but did not wait
for whatever was already running, so a caller could proceed straight to
browser.close() while a call from just before shutdown was still
finishing on the dedicated thread.

shutdown() now waits, bounded, for that in-flight call to drain before
returning. Costs nothing in the common case - awaitTermination returns
immediately once the one worker thread and its queue are both idle,
which is where an instance sits between calls - and only the bound in
the rare case something was genuinely still running.

Known, and disclosed in the KDoc rather than claimed away: this narrows
the race for the common case (a call that was always going to finish
quickly) rather than closing it. It cannot help when the in-flight call
is itself the wedge this class's own deadline exists for - waiting
unboundedly for a genuinely stuck call would reintroduce the exact freeze
BoundedBrowserCall exists to prevent, just moved from a plugin's await
into every caller's teardown path. There is no JxBrowser API on this
version able to interrupt a blocking round trip already inside the
native call, which is the whole reason the call is confined to its own
thread instead of cancelled - closing that window completely needs
JxBrowser's own cooperation, which the issue's own analysis already
concluded is unavailable.

Tests: shutdown waits for a fast in-flight call to finish before
returning, and does not wait past its own drain timeout for a wedged one
- the same before/after pair the rest of this test file already uses for
BoundedBrowserCall's deadline.

* fix(browser): defer native close until admitted calls drain

Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring.

* fix(browser): bound profile waits and clarify deferred cleanup ownership

Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit.

* fix(browser): schedule disposal after early UI teardown failure

* ci: create launcher directory before Claude native installation

* Revert "ci: create launcher directory before Claude native installation"

This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0.

* test: keep warmup registry ordering check on one event loop

* test(browser): normalize CRLF in disposal source wiring guards

---------

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

* feat(tabs): show a speaker glyph on tabs that are playing audio (#314)

* feat(tabs): show a speaker glyph on tabs that are playing audio (#308)

* fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush

1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister.

* fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures

- BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire.

* fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file

* fix(tabs): publish audio state by browser ownership and keep icon layout stable

---------

Co-authored-by: rehannayeem0786 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat: connect chrome density settings and keyboard controls (#305)

* feat: wire the chrome density scale into settings and small-screen defaults

ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on
main and every bar (BossTitleBar, BossTopBar, the main tab bar,
BossBottomBar) already read its height from BossChrome.dimens - but
LocalChromeDimens was never provided anywhere, so it silently resolved to
its staticCompositionLocalOf default (Comfortable) always. The scale was
built and unreachable.

This closes that gap:

- WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so
  an existing install's settings file - which has never heard of this key
  - decodes to exactly the chrome it already had; no settings-version bump
  needed).
- BossApp.kt now provides LocalChromeDimens from that field, host-only and
  outside BossAppCompositionLocals (plugins never draw host chrome).
- A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious),
  indexed in Settings search.
- A fresh install on a small screen (< 1000dp logical height, clearing the
  13" MacBook Air's ~931-956pt from #239's own measurements) now defaults
  to Compact and starts with the bottom bar off - the one bar this manager
  can still reclaim itself, since the side strips are already off by
  class default. Toolkit.getScreenSize() is read once, wrapped in
  runCatching (HeadlessException off a display must not break a fresh
  install), with a pure defaultDensityFor(screenHeightDp) so the decision
  is unit-testable without a display.

Addresses #239, scoped down from the full issue. Out of scope in this
pass, deliberately:

- The app does not yet report its own chrome budget as a measured
  percentage - the issue's own "state it, don't argue it" ask. Reachable
  later from the same ChromeDimens source, but needs a spot to render it.
- The title bar's 27dp "Boss Console" label row is untouched - collapsing
  or merging it with the tab bar is a separate, riskier layout change than
  a density scale.
- No regression intended to the existing per-bar show/hide switches or
  focus-mode edges: density only changes how much room a bar that is
  already on screen takes, never whether it is shown.

* fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line

Address review feedback on #305:

- Removed showBottomBar = density != ChromeDensity.COMPACT from
  getDefaultSettings(). ChromeMetricsTest already shows the shipped
  macOS defaults reach 93.3% of a 931dp window at Comfortable, so
  #239's >=90% bar is met without touching the status bar - and that
  bar's own KDoc argues it must stay on ("the only always-on readout").
  It was also reachable from the settings-load catch fallback, so a
  transient read error could silently drop the bar and have the next
  save persist the loss. Compact now only changes density, nothing else.

- Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's
  default MaxLineLength (120), which applies here since .editorconfig's
  140 is a ktlint-only setting and this line wasn't in the baseline.

* feat: add chrome density setting

* feat: add small-screen chrome defaults

* feat: add chrome density controls and small-screen defaults

* fix(ui): preserve lean density defaults and test density controls

* style: format multiline screen-height lookup

* fix(ui): separate density recovery and make editor chords opt-in

* style: wrap density default documentation

---------

Co-authored-by: AdityaK-iiita <adityakolate0070@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): stop host resource fallback after unload (#325)

* fix(plugins): stop host resource fallback after unload

* fix(plugins): preserve resource warning for a missing result

---------

Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* Handle closed browser frames during navigation callbacks (#400)

* Handle closed browser frames during navigation callbacks

* test(browser): retain closed-event guard after navigation integration

* fix(browser): preserve helpers on PID failure and recognize closed transports

* style(browser): wrap review diagnostics for detekt

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* refactor(window): pin the panel content alignment width (#398)

* fix(window): restore strict width constraints for nested panels

Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation.

* Cover panel content alignment across width changes

* Keep panel layout regression in the existing app test package

* Document panel alignment contract and pin filling content width

* Wrap the panel width assertion to satisfy detekt

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* #320 - Fix quick actions vanishing in short tab rail (#328)

* #320 - Fix quick actions vanishing in short tab rail

* Fix: detekt & ktlint checks

* Fix rail action budgets and reversible fallback wiring

* Fix resize test assertion import and update rail coverage note

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(crash): refine scroll boundary and bound error sanitization (#350)

* fix(crash): refine scroll boundary and bound error sanitization

* Avoid exposing a partial token at the crash message limit

* Document sanitizer input bound and verify expansion ordering

---------

Co-authored-by: Aishwary Anand <aishwary.cd22@bmsce.ac.in>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(logging): redact private hostnames (#346)

* fix(logging): redact private hostnames

* Handle private hostname punctuation and preserve diagnostic ports

* Format hostname punctuation regression assertion

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat: Opening an .html file should ask open as a file or webpage (#333)

* feat: Opening an .html file should ask open as a file or webpage

* Fix HTML prompt delivery and persisted preference ordering

* Cover HTML routing and correct review test fixtures

* Honor HTML navigation intent and release pending prompts on failure

* Isolate HTML prompt handling from the general dialog host

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugin-store): parse timestamps consistently in list and detail metadata (#379)

* Fix: Implement proper ISO timestamp parsing in PluginStoreClient

* fix(repository): implement robust ISO timestamp parsing for Supabase payloads

* style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught

* fix(plugin-store): implement plugin metadata timestamp parsing

PluginStoreClient.parseTimestamp was a stub that always returned 0L, so
every plugin fetched from the store showed "Last Updated"/"Published" as
the Unix epoch in the Toolbox UI regardless of what the server actually
sent (#337).

Parses the ISO-8601 timestamp (the shape the store sends, e.g.
2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant,
falling back to 0L for a blank, missing, or malformed string rather than
throwing - matching the field's own empty-string default for a response
that omits it. java.time rather than adding kotlinx-datetime as a new
dependency: this module already uses java.util.* directly in the same
commonMain source set (it has one real target, jvm("desktop")), so nothing
is gained by introducing a second time library for one function.

Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the
convention already established next to it in
PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset
timestamp, a blank string, and a malformed string.

* Fix timestamp normalization edge cases and verify both response mappings

* Use release publication dates and pin timezone-independent fallback

---------

Co-authored-by: Sanjay <sanjaysaini4423@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat(remote-ui): authenticate and expose remote UI surfaces (#348)

* feat(remote-ui): authenticate remote UI process identity

* feat(remote-ui): place authenticated remote UI surfaces

* fix(remote-ui): close ownership and placement lifecycle races

* test(remote-ui): expose placement recovery cases to JUnit

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat(dashboard): add shared read-only What's New release feed (#381)

* Add What's New release feed to dashboard

* Serialize update settings persistence

* Remove unused update settings import

* fix(updater): retain realtime refreshes during shared release fetches

* style(updater): format shared release fetch regression tests

* fix(updater): coalesce release refresh bursts and preserve visible history

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(language): consolidate host and out-of-process language-id tables (#358)

* fix(language): consolidate host and out-of-process language-id tables

EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor)
each hand-maintained their own extension-to-language-id table because
boss-app-editor - a plain JVM module compiled to a GraalVM native image -
could not depend on anything in composeApp. They disagreed: EditorServiceImpl
named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and
EditorServiceImpl was missing more than forty ids the other table had
(fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75).

Moves the table into a new plugin-platform/plugin-language-types module -
dependency-free by design, so it carries nothing extra into boss-app-editor's
native-image build - and has both EditorLanguages and EditorServiceImpl read
from it. This is the first time anything under modules/ depends on
plugin-platform/; verified compiling and passing tests before committing to
the approach.

EditorLanguages keeps its existing public API unchanged (same values for
every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest
needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry
and "plaintext" fallback as local additions on top of the shared table,
rather than folding them into it: proto isn't part of the boss-file-types.json
default-app-association surface the shared table backs, and "plaintext" is
this service's own gRPC default, not a value composeApp reads.

Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously
no tests existed for this class at all) - the latter pins the actual bug fix,
that detectLanguage("sh") now returns "bash" instead of "shell".

Out of scope, and disclosed rather than silently dropped: two of the five
duplicate tables the issue names - the editor-tab plugin's own
LanguageDetection and BossEditor's lexer registry - live in separate
repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in
plugin-icons is also not consolidated: it keys icon selection off the raw
filename to preserve distinctions a language id can't carry (package.json,
yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different
brand icons) and never computes a language id in the first place, so
routing it through this table would need a separate icon-selection redesign.

* fix(language): keep file-association drift checks on the shared table

* fix(language): apply shared filename rules to editor file opens

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(editor): contain write-local stack overflow and preserve fatal errors (#396)

* Make writeFileContentSafe actually safe: catch the Errors it was named for

`writeFileContentSafe` exists to return false rather than throw. It did not hold
that contract:

    } catch (e: Exception) {
        fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e)
        false
    }

StackOverflowError is an Error, not an Exception, so it went straight through -
out of writeFileContent, out of the editor_write_file MCP handler, and to the
caller as a bare StackOverflowError instead of the handler's own
"Write failed for <path>". The warn that would have named the file never ran
either.

That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two
independent reports of it, on unrelated content, and #27's complaint is precisely
the missing diagnosis: the error "gives no indication of which input caused it".

StackOverflowError and OutOfMemoryError are caught explicitly rather than
widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and
readFileContentSafe in this same file already treats OutOfMemoryError as a
reportable outcome. Throwable would also absorb LinkageError and ThreadDeath,
which are not this function's to swallow - there is a test asserting a
NoClassDefFoundError still propagates.

The failure log now names the path and the character count. Not the content:
these writes carry whatever the user is editing.

**This does not fix the write.** Whatever recurses is upstream of this function,
which is mkdirs plus writeText - I ruled out the plugin's handler, this write,
and the host's MCP argument parsing, and said so on the issue. What changes is
that the failure is reported honestly instead of escaping, which is where a
diagnosis can start.

The body moves to `guardedWrite` with an injectable write, because a test JVM
cannot be made to overflow the stack inside writeText on demand.

Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt.

Refs risa-labs-inc/boss-plugin-editor-tab#18, #27.

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

* docs(editor): clarify write guard scope and prior recursion fix

* fix(editor): preserve fatal heap errors in write guard

---------

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

* fix(state): apply version-checked JSON merge patches (#361)

* Fix: Implement proper ISO timestamp parsing in PluginStoreClient

* Feature: Implement JSON Merge Patch for plugin delta state synchronization

* fix(state): validate delta bases and exclude duplicate timestamp scope

* fix(state): reject malformed and deeply nested patches

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363)

Bumps `grpc` from 1.83.1 to 1.84.0.

Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

---
updated-dependencies:
- dependency-name: io.grpc:grpc-netty
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-protobuf
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-stub
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-services
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-util
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364)

Bumps `protobuf` from 4.36.0 to 4.36.1.

Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1

Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Commits](https://github.com/protocolbuffers/protobuf/commits)

---
updated-dependencies:
- dependency-name: com.google.protobuf:protobuf-kotlin
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.google.protobuf:protobuf-java
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): defer stale jar cleanup until restart (#340)

* fix(plugins): defer stale jar cleanup until restart

* fix(plugins): satisfy quality checks

* Preserve staged plugin artifacts when another plugin updates

* Require cleanup intent and retain artifacts with unordered manifest versions

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): defer fluck-browser hot-reload to a restart (#352)

* fix(plugins): defer stale jar cleanup until restart

* fix(plugins): defer fluck-browser hot-reload to a restart

Hot-reloading fluck-browser force-unloads the classloader that created its
JxBrowser native views. Every open tab - and every tab opened afterward,
since the factory that would recreate the view is gone too - draws an empty
box, and a second hot-reload does not recover it; only a full restart does.

HotReloadPolicy names plugins that must never be swapped in place, matching
the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at
every path that can force-unload a live plugin to apply an update: the
resetPluginInstances/doReloadPlugin path that actually reproduced this bug,
the menu-driven Reload action, the Toolbox update flow, and the store
installer. For a not-hot-reloadable plugin, the new jar is staged into
installed.json without touching the running instance or its open tabs, and
a status message tells the user to restart to apply it - the next cold
start loads the update with a fresh classloader.

Also fixes two call sites that asked "restart dependent plugins?" before
checking whether an unload was even going to happen, which produced a
confusing prompt for an unload that a deferred update was never going to
do.

* fix(plugins): satisfy quality checks

* Preserve staged plugin artifacts when another plugin updates

* Validate deferred browser artifacts and reject ineffective downgrades

* Require cleanup intent and retain artifacts with unordered manifest versions

* Preserve unloaded-browser recovery and unify safe deferred selection

* Format scoped update cleanup integration

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365)

* Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135)

reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest.

* Serialize plugin spawn registration with reaping and preserve replacement handles

* Fence late process creation without blocking shutdown and cover recovery

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): complete loader cleanup after disposal linkage errors (#302)

* fix: API hot sw…
…668)

* fix(secrets): make the secret listing RPCs fail soft on one bad row

BossConsole#620: get_user_secrets, search_user_secrets and
get_user_secrets_with_shared each decrypt password_encrypted and
recovery_codes_encrypted with a bare call to decrypt_text inside a
set-returning query. decrypt_text raises on an undecryptable value - a
wrong key after a partial key rotation, storage corruption, or an
HMAC/padding mismatch - and one raised row aborts the whole query, so
the caller sees every one of their secrets fail to load rather than
just the bad one.

twofa_secret already reads through safe_decrypt_twofa_secret (NULL on
failure). recovery_codes_encrypted already had a safe wrapper too -
safe_decrypt_recovery_codes, whose own doc comment says "used
internally by get_user_secrets functions" - but whichever later
migration touched these RPCs stopped calling it and fell back to raw
decrypt_text, so it sat unused. password_encrypted never had a safe
wrapper at all.

Adds try_decrypt_text(text), mirroring safe_decrypt_recovery_codes's
BEGIN/EXCEPTION WHEN OTHERS THEN RETURN NULL shape for a scalar
result, locked down the same way safe_decrypt_twofa_secret is (no
client role gets EXECUTE). Routes the password read in all three RPCs
through it and the recovery-codes read back through the existing safe
wrapper. One corrupt row now surfaces as a blanked field - password
NULL, recovery_codes [] - while every other secret in the page still
loads.

Deliberately not named safe_decrypt_*: ops/rotate_master_encryption_key.sql's
re-encrypt loop treats every function matching that name pattern as a
mapped bespoke-format adapter and refuses to run if one is unmapped.
try_decrypt_text wraps the same plain envelope that script already
re-encrypts directly, so it must stay outside that sweep.

Scoped to the three user-facing listing RPCs only; the operator-run
backfill/rotation paths keep using raw decrypt_text and should still
fail closed.

* Fix installed-client compatibility and preserve systemic decryption failures

Includes desktop regression coverage from #621 by Rushikeshiitb and review repairs by kshivang; related independent #652 work by akashjadon04 informed the review.

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
…idge (#666)

* fix(kernel): require verified caller identity for the project-data bridge

BossConsole#53: ProjectDataServiceBridge had no identity check at all.
Any process able to open a connection to the kernel IPC server - not
only the plugins the host itself loaded - could watch every recently
opened project's path (which routinely contains a username) with no
credential, and could force an arbitrary window to switch its open
project via selectProject. That is not only a UI surprise: a project
switch publishes a ProjectChangeEvent on the application-wide event
bus every installed plugin can subscribe to, so an unauthenticated
caller could trigger that broadcast on demand.

Applies the same fail-closed pattern already used for ActiveTabs,
Download, Git, Log, PluginUI, RoleManagement, RunConfig and Supabase:
every unary RPC requires AUTHENTICATED_PROCESS_ID, the watch stream
captures CURRENT_IDENTITY before collecting and re-checks it on every
emission so a revoked token stops a live stream, and every mutation
gets an attributed audit log line. SplitView remains the one
unguarded service on this server.

Rewrote ProjectDataServiceBridgeTest against a real gRPC server (the
existing tests called the bridge directly, which the new gating would
have refused outright) - refusal-with-no-credential, authenticated
mutation, revoked unary and revoked stream cases, plus the two
existing routing guarantees now exercised over an authenticated
channel: selectProject's UI-dispatcher confinement and
watchRecentProjects reading the process-wide ProjectState singleton
rather than a per-window mirror.

Also folded the stale, repeatedly-drifting enumeration of which
bridges fail closed out of BossIpcServer's own KDoc - it named only
two of what are now nine - in favor of pointing at each bridge's own
KDoc instead.

* fix(review): clarify project identity guarantees and harden test lifecycle

* style(test): keep filtered flow chain continuation together

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
Fixes #641. Consolidates the independently implemented clone-timeout work from #659 and #660.

Abhiram Nair authored the original onExit lifecycle, deterministic process tests, real process-tree coverage and public clone cleanup/retry repairs. Antriksh independently authored the interruptible-wait alternative and real-Git acceptance tests; target preservation, ordinary failure and final-completion coverage are incorporated with explicit credit. Maintainer repairs serialize interruptible progress delivery, make callback failures advisory and preserve successful exit during delayed notification.

Co-authored-by: passionate-coder26 <abhipersonal656@gmail.com>
Co-authored-by: Antriksh <antrikshsingh850@gmail.com>
…ge (#672)

* fix(kernel): require verified caller identity for the split-view bridge

BossConsole#53: SplitViewServiceBridge had no identity check at all.
Any process able to open a connection to the kernel IPC server - not
only the plugins the host itself loaded - could force an arbitrary
window's active panel to navigate to an attacker-chosen URL via
openUrlInActivePanel (with no confirmation for an ordinary http(s)
URL, and no confirmation gate at all for most boss:// hosts), open an
arbitrary file path with no confinement via openFileInActivePanel/
openFileInEditor/openFileInBrowser/openFileAtPosition, manipulate
panel/tab selection, or overwrite a workspace snapshot under an
attacker-chosen name via preserveCurrentState.

Applies the same fail-closed pattern already used for ActiveTabs,
Download, Git, Log, PluginUI, RoleManagement, RunConfig and Supabase:
every unary RPC requires a verified AUTHENTICATED_PROCESS_ID, and
every mutation gets an attributed audit log line. This is the last
bridge in this PR's own scope; ProjectData's identical fix is in
flight separately (#666).

New SplitViewServiceBridgeTest against a real gRPC server: every RPC
refused with no credential and never reaching the provider, an
authenticated caller can drive every operation, and a revoked caller
cannot invoke a unary RPC.

* test(kernel): verify split-view identity coverage and clarify scope

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
* feat(search): index open tabs by their URL and file path (#606) (#648)

Global Search matched an open tab only against its title, and TabResult
hardcoded url = null / filePath = null on every hit. A browser tab whose
title never mentions its domain, or an editor tab someone wants to find
by path rather than filename, could not be found at all.

searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and
an editor tab's EditorTabInfo.filePath, through the same substring-
containment gate (proseScore) searchRecentPages already uses for a
page's URL, since a fuzzy subsequence match over a whole URL or path is
close to meaningless. TabResult carries the real url/filePath instead
of null, and the tab row's subtitle now leads with the workspace name
(disambiguating identical URLs open in different workspaces, or across
windows) followed by the url/filePath/tabType, in place of the old
"tabType • workspaceName" line.

11 tests cover: URL-only and file-path-only matches, both fields
populated for the right tab type and left null for the other/a blank
path, a short query not matching a URL by fuzzy accident, title+URL
both matching taking the higher (title) score with title highlight
ranges preserved, live navigation changing which URL matches (and the
old URL no longer matching), directory-path matching without a
subsequence false-positive, and no-open-tabs contributing nothing
rather than failing.

* fix(browser): replace superseded capture requests (#509)

* Stop anon reaching schema public by inheritance, and scope identity to real orgs

The BOSS Supabase anon key is compiled into this repo, which is public. Anything
`anon` can execute is therefore executable by anyone. Verified on 2026-09-08:

  * get_encryption_key() returned the Vault master encryption key, unauthenticated.
    decrypt_text() was anon-callable beside it, making a decryption oracle over
    everything encrypt_text protects - user secrets and the QBO/Google broker
    credentials.
  * find_user_by_email() confirmed any address and returned its uuid.
  * list_shareable_recipients() returned 152 users WITH full email addresses to
    any self-registered account, and 82 @risalabs.ai addresses on a search.
  * arcade_leaderboard() and arcade_bs_standings() published the roster of
    everyone who had opened the Arcade, unauthenticated.

None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on
every new function, PUBLIC includes anon, and this project's default privileges
add anon on top - so a SECURITY DEFINER function is internet-callable from the
moment it is created. Both revokes are traps in mirror image: `from public`
leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and
ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses
the entry and new functions still get `=X`).

  20260908000000  event trigger: revoke PUBLIC+anon on every function created in
                  schema public. An explicit `grant ... to anon` after the create
                  still wins - anon access must be stated, never inherited.
  20260908010000  org_visible_users / org_is_vetted / user_display_name: ONE
                  definition of "may this account learn who that account is",
                  shared by the Arcade and poker rather than copied. Vetted means
                  a human approved the join, which excludes the catch-all `boss`
                  org every account joins on signup (153 members, 20 domains).
  20260908020000  route list_shareable_recipients through both. It was already
                  org-scoped, which is why it looked right; it accepted ANY
                  shared org.
  20260908030000  one-time sweep of the ~30 functions that already had the
                  inherited grant. The RBAC mutators fail closed
                  (is_user_admin(NULL) is false), so this is defence in depth,
                  not a patched escalation.

The rule is a SET, not a per-target predicate: as a per-row qual it is pushed
below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score
rows for 45 players, against 37ms.

supabase/audit/identity_disclosure_audit.sql is the standing check, because "we
fixed the leak" is not a durable claim. All four checks report HEALTHY.

Left deliberately anon-callable, each documented where it is kept: the plugin
store browse API, the three helpers called from RLS policies on anon-readable
tables, and custom_access_token_hook. Verified after the sweep that anonymous
plugin-store browse and signed-in RBAC reads both still work.

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

* Add the master-key rotation procedure, and rotate

get_encryption_key() was anon-callable, so the Vault master_encryption_key was
retrievable by anyone holding the project anon key - which ships compiled into
this public repo. Revoking access does not un-disclose a key, so it was rotated
on 2026-09-09 and the 184 rows encrypted under it re-encrypted.

Scope of the original exposure, stated precisely because the capability was
worse than the reach: decrypt_text() was anon-callable beside it, so an attacker
holding ciphertext from ANY channel - a backup, an export, a screenshot - could
decrypt it through the API without even needing the key. But no bulk read path
to that ciphertext existed: qbo_token_state and google_token_state have no
client grants at all, and all 178 secrets rows have org_id NULL, so the secrets
policy reduces to owner-only. This was a serious latent vulnerability - one
org-shared secret, one RLS slip, or anyone with backup access would have had
plaintext - rather than a confirmed bulk disclosure.

The script is kept because rotation is not a one-off. It is atomic (one DO
block), self-verifying (md5 of every row's plaintext captured before, re-derived
after through decrypt_text so the check covers both the swap and the data), and
a mismatch or row-count drift raises and rolls back, so a broken rotation cannot
commit. Verified after the run: the live key differs from the disclosed one,
184/184 rows decrypt under it, and the disclosed key opens 0 rows.

The old key is retained in the Vault under an explicit name. Do NOT delete it
while pre-rotation backups exist - they are encrypted under it.

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

* fix(db): close signed-in crypto access and verify visibility boundaries

* test(db): exercise repeated key rotation and exact anonymous ACLs

* test(db): grant fixture role membership on Supabase Postgres

* test(db): inline rotation source for isolated pgTAP mounts

* test(db): keep generated rotation SQL out of source control

* fix(db): reserve identity-taking store mutators for the edge service

* fix(db): serialize rotations without requiring direct Vault writes

* fix(db): address review gaps in system-org and rotation coverage

* test(db): verify core secrets across hex and broker-free rotations

* test(db): execute standing audit and missing-signature regression

* fix(db): enforce revoke postconditions and harden rotation verification

* test(db): enable recovery metadata in the authenticated fixture

* test(db): use the schema-supported authenticator type

* Address remaining database review quality findings

* Fix invoker view audit boolean parsing and test timestamp bounds

* Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift

Addresses the handoff's database-integration item and the substantiated findings
from the current-head review. Everything below was executed against the PR's own
Supabase preview branch, not asserted from reading.

**The blocker: rotation refused to run once #417 landed.** The guard was a
blanket "TOTP is installed, refuse everything". It is now an adapter. The stored
form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing
differs - so the column map gained an envelope prefix and rotates like the rest
once the prefix is stripped and re-applied. Three things this needed that reading
the diff would not have shown:

  * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a
    storage envelope'), so the re-encryption update is rejected outright. The
    reviewed trigger is now disabled for the update and restored after, inside
    the same transaction.
  * safe_decrypt_recovery_codes returns jsonb, not text, so verification through
    the real read path needed a cast. Both fingerprints now go through each
    column's OWN application read path rather than decrypt_text, which also
    removes the step-1/step-5 asymmetry raised as U5.
  * a step 0 pre-check reports rows that are already unreadable, instead of
    letting a safe_decrypt_* NULL surface at the end as "a row was missed".

The rejection contract is preserved, not removed: an unknown envelope version,
an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped
table each still refuse. All three are now regression-tested, along with the
recovery-code column that was previously never exercised (its fixture was
vacuous - create_secret writes no secret_metadata row without p_twofa_enabled).
Verified: three consecutive rotations, TOTP/recovery/password all intact, v1
envelope preserved, trigger re-enabled and still enforcing its own contract.

**CREATE EXTENSION was impossible.** The fail-closed guard aborted the first
routine of any extension installed into public: `create extension pgtap` failed
with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces
operators to disable it in order to install an extension is a guard that ends up
disabled. Extension-owned routines are now exempted with a warning, and the
exposure stays visible as advisory CHECK 1x rather than vanishing - it reports
pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers
are called from column DEFAULTs and CHECK constraints, which are evaluated with
the DML role's privileges. No extension owns a function in public on a clean
database, so no deployed behaviour changes.

**The repo was weaker than production.** find_user_by_email, get_session_status
and update_api_key_last_used were locked down on the live project on 2026-09-08
but never captured as a migration, so a fresh deploy re-opened them.
find_user_by_email is a user-enumeration oracle over auth.users and signup is
open. The standing audit caught this itself on the preview - CHECK 2 named it -
which is the drift that check exists for. Both remaining callers use
SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps
EXECUTE and neither path changes.

Also: the guard no longer hard-fails on a database without the Supabase roles (a
plain restore), and the vault fixtures no longer assume the key is absent (U3) -
both suites failed outright on any database that already had one. Retained-key
assertions measure this run's delta instead of an absolute count.

Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all
clean; the standing audit reports no failing gated check.

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

* Exercise the anonymous read path, not just the grant

The keep-list assertions only checked has_function_privilege for the seven
allowlisted signatures. That proves the grant, not the path: an RLS policy
expression is evaluated as the QUERYING role, so revoking a policy helper turns
an anonymous SELECT into 'permission denied for function ...' rather than an
empty result, and no privilege assertion can see it.

The seeding is the part that matters. A policy expression runs PER ROW, so on
an empty table it is never evaluated - and plugins and user_roles are both empty
on a fresh database, which made the first version of this probe pass no matter
what had been revoked. Caught by trying to make it fail.

Sensitivity is stated from measurement, not assumption: with a row present,
revoking can_view_plugin_row from anon does fail the probe and revoking
authorize does not, because permissive policies are ORed and short-circuit. A
helper in a policy that never has to be evaluated cannot be detected this way by
any test, so the comment says so rather than implying broader coverage.

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

* Make the gated audit checks and the test generator provable

Both halves of this are the same failure: a suite that is green while proving
nothing. I hit that twice writing the previous commits - a recovery-code fixture
over a secret_metadata row create_secret never wrote, and an anonymous-read
probe over an empty table whose RLS policy therefore never evaluated - so these
are not hypothetical.

Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY,
while every advisory check already had a fixture-based detection test. An
inverted `not exists`, a wrong role literal or a typo in the evtenabled test
would have left the gate permanently green. Each is now broken deliberately and
asserted to report it: a routine granted to anon AFTER creation (which is how
one gets past the event trigger), a revoked deliberate anonymous grant, a
disabled event trigger, and a crypto routine exposed to authenticated. They have
teeth by construction - the assertion is that the finding appears, so a broken
audit query fails them.

Finding 8. The generator gained --check, which regenerates in memory and
compares without writing, and orphan removal for a generated suite whose .sql.in
has been renamed or deleted - that file otherwise keeps being executed forever
with stale content. Orphans are identified by a provenance banner rather than a
hard-coded list, so a hand-written suite is never touched; verified that
explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by
making it fire: a hand edit and a planted orphan are both reported by --check,
and generate removes the orphan.

CI now runs generate and --check as their own step, so a preparation failure is
visible in the log instead of being attributed to the test run.

Preview: rotation 16/16, db access audit 15/15, visibility 56/56.

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

* Announce every project change, not only the plugin-initiated ones

Panels could come up empty and stay empty, and the reason was that the
startup restore told no one.

ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject,
which is the path a PLUGIN takes. The two callers that matter most bypass
it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject
directly when it restores the workspace, and BossTopBar's picker goes
through selectProjectInWindow. Neither published anything.

The ordering that produced the empty panel: DefaultPlugin is constructed in
a DisposableEffect keyed on registries and window state, not on
selectedProject, and it kicks off the plugin scan asynchronously while
BossAppScaffold composes and the sidebar builds panels immediately. The
restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON
reads. When those reads are slow the panel is built first, reads the ""
that WindowProjectState seeds itself with, and renders its no-project state.
The restore then lands, silently, and nothing remounts the cached component.

Publishing from the state itself rather than from one caller catches all
three. The bus is MutableSharedFlow(replay = 0), so a publish that never
happens cannot be recovered by a later subscriber - which is why this could
not stay per-caller.

The publish is removed from selectProject rather than added alongside the
collector; keeping both would double-fire on the plugin path.

previousPath is seeded from the current value so the StateFlow's replay of
it is not announced as a change. That seed is "" at startup, and telling
every plugin the project just became "" moments before the real restore
arrives is precisely the clear-yourself signal worth avoiding.

One collector per window: projectDataProvider is `by lazy` on DefaultPlugin,
which is per-window, so this cannot stack.

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

* Publish from the state's own callback, not from a lazily-built provider

The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl`
put the fix behind two `by lazy` initializers. `projectDataProvider` and
`applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only
host path that creates the bus at all. So the collector started only if some plugin
touched both before the workspace JSON reads finished - and "the reads are slow" is
the premise of the bug. Touched after the restore, the collector seeds to the
restored path and announces nothing; never touched, no caller is announced at all.

The announcement now hangs off the `ProjectSelectionCallback` that
`WindowProjectStateRegistry` installs when it builds the window's state, which
`BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is
the sole mutator of the selection and invokes that callback synchronously, so every
caller is covered with no coroutine, no scope and nothing lazy in the path. Being
synchronous also makes `previousProjectPath` a faithful history rather than
best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C.

The two registry entry points carried a copy of the wiring each; they now share one
`newState`, so the announcement cannot be installed on some windows and not others.

Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a
no-op whenever the bus had not been created, so on a build where no installed plugin
had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`,
not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered.
It now creates the bus instead of dropping. `getInstance` also re-checks the registry
on every call rather than only at creation, so an instance that exists while the
registry is empty can no longer strand the host's publisher.

Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and
`DefaultPlugin.dispose()` releases it through a named delegate, alongside
`logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a
`SupervisorJob`, matching `pluginScope`.

Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being
announced, the previous-path chain, the seeding rule, the same-path suppression,
both registry entry points, and the bus being created rather than dropping the event.
Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean.

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

* Round 2: honest bus wording, atomic previousPath, three more tests

Review (1/2) and (2/2).

1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no
   registered publisher means nobody holds the bus, so it has no subscribers and that
   first event still reaches no one. The comment, the test name and the PR body all
   claimed otherwise. Reworded to what it actually buys: the host stops being
   permanently silent while it waits for a plugin to touch `applicationEventBus`.

3. `previousPath` was check-then-act on a plain field, and
   `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to
   Main (verified: `KernelBootstrap` registers it, the bridge calls
   `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read
   and the write are one operation.

5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields
   are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the
   lock is uncontended after the first call and removes the question. The
   bus-before-publisher ordering is now written down, including why a publish landing
   in that window loses nothing.

6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is
   documented as defensive and test-reachable, and defaulted to "".

7. Three tests added:
   - a plugin-initiated selection is announced exactly once (catches both a silent
     regression to zero and an accidentally re-added publish);
   - the registry test now asserts the recent-projects half of the callback ran -
     deleting it from `newState` previously left every test green;
   - `dispose()` stops the recent-projects collector, which is the whole reason
     `projectDataProviderDelegate` became a named lazy.

   `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider
   collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so
   its collector silently never ran. Unconfined also makes the dispose test
   deterministic rather than a sleep.

Smaller: `SupervisorJob` comment describes it as future-proofing (there is one
collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of
disposing the provider is documented on `dispose()`.

Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but
it changes `events()` semantics for every subscriber and every event type on a bus
whose consumers are out of tree. The body now says the race is narrowed rather than
closed, and names the api-side `selectedProject` StateFlow as what closes it.

`:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean.

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

* Round 3: unconditional announcement, ordered publish, drop dead register

Review 3.

2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe"
   paragraph, since this widens *when* a filesystem path reaches every installed
   plugin.

3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only
   the `actual` had been corrected. Fixed, and it now states the part that IS still
   best-effort: replay = 0 means an unsubscribed event is gone either way.

4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw
   there skipped it AND left `previousPath` stale - every later selection reporting a
   previous path one step behind, silently, for the session. Now `try { recents }
   finally { announce }`: order preserved for anyone reading recents off the event,
   the announcement unconditional, the throw still propagating.

5. `getAndSet` closed the read-modify-write but not the publish order, so two
   concurrent selections could emit out of order. `previousPath` and the publish are
   now in one `synchronized` block (the AtomicReference is gone - the lock subsumes
   it). The KDoc says plainly what this still does not settle: `WindowProjectState`
   writes `_selectedProject.value` then calls the callback with no atomicity, so state
   and last event can still disagree. Closing that means locking upstream.

6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value`
   wholesale and could land mid-test, dropping a just-added path - only where
   `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A
   once-per-JVM settle in `@BeforeTest` puts that single file read before any test.
   The test paths stay deliberately non-existent, now with a comment saying why: the
   fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose
   directory is gone, so a real temp directory would leak where these self-heal.

7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`),
   and it overwrote an existing entry - handing back a state with a fresh announcer
   seeded to "" for a window that already had a project. `newState` stays; it is what
   keeps the wiring from drifting.

8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside
   the lock now, and leaving it reads as if a fast path survives.

Also: a test for the actual regression path, `applyWorkspace` announcing the project it
restores, rather than a comment claiming to imitate it.

Answering the grep you could not run: the only `projectChanges()` consumers in
boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign
`_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat
publish as a reload nudge. Recorded in the announcer comment.

Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not
closed"), naming the mirror case and the api-side StateFlow that closes it.

`:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean.

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

* Round 4: split the dispose out, stop tests writing the real ~/.boss

Review 4. Items 1 and 3 were the before-merge ones.

1. Verified: ProjectDataServiceBridge.watchRecentProjects collects
   provider.recentProjects, a StateFlow, so cancelling the provider's scope on
   window close leaves that gRPC stream open and silently frozen for every
   out-of-process plugin. Taking the first option and SPLITTING the dispose out.

   Note this reverses what round 1 asked for, and the justification changed rather
   than the opinion: once the announcement moved to the registry callback,
   ProjectDataProviderImpl went back to being exactly what it was before this PR, so
   the leak is pre-existing and unrelated to the subject. Closing it properly means
   the bridge reading ProjectState directly instead of a per-window provider, which
   is its own change. The reasoning is on the class so it is not re-fixed by accident.

3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict
   real ones from the developer's picker and no cleanup restores them.
   systemProperty("user.home", <build>/test-home) on the Test task. One run with it in
   place shows the existing suite was writing far more than recents to the real home -
   window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json,
   recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all
   land in test-home now. The settle is gone with it: no recent-projects.json in
   test-home means the wholesale reload never happens.

2. publishSystemEvent now refuses the bus != null && systemPublisher == null state
   instead of falling through into a bus that may not be the registry's, with one
   warning rather than one per event. The comment at the fallback is true as written.

4. Both stale comments fixed (one construction path, not two).

5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is
   testable - ProjectState is an object, so a hard-coded call cannot be made to fail.

6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys
   (ordering), what it costs (an inline subscriber runs holding the lock inside
   selectProject), and that the contended path is untested.

7. The serial-execution dependency is recorded in the test class KDoc.

Plus: multi-window isolation, and a test that makes the recents update throw.

One thing this round caused and fixed: removing dispose() left two
ProjectDataProviderImpl collectors alive on the test dispatcher. On a
TestCoroutineScheduler a live coroutine is something every later runTest waits on, and
the git classes timed out at 60s in full-suite runs while passing in isolation.
Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher():
same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a
real argument for the follow-up - without dispose() the provider cannot be constructed
in a test without leaking.

:composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean.

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

* Round 5: reset test-home per run, stop getInstance stealing a publisher

Review 5.

1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting
   there from the previous run. doFirst now deleteRecursively() before mkdirs(), so
   the home is fresh per run rather than merely private. Without it the redirect only
   helped the first run and ProjectState.init's load reintroduced the same race from
   the second. Verified by running the full suite twice back to back.

2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and
   `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest,
   BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests
   both, so getInstance cannot take a publisher away from whoever set it.

3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning -
   partialRegistryWarned is a one-shot process global, so "it warns" is unassertable
   after the first test to trip it. Uses a stub bus so the half-registry state is real.

4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant,
   previousPath advances before the publish), so it is not "fixed" later. The
   lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block.

5. Taken, including the optional ones:
   - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally
     discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching
     Throwable is the contract, and both are rethrown.
   - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main).
     This removes the global setMain AND the round-4 collector leak at the source
     rather than working around them, without reopening the DisposableProvider split.
   - Three more pins: a null window state announces nothing, applyWorkspace
     (restoreProject = false) announces nothing, unregister + getOrCreate restarts the
     chain at "".

Not taken, with reasons: jvmArgumentProviders (the current form works, is
config-cache-serialisable and passed CI on three OSes); widening the user.home redirect
to plugin-path-utils (its test only mkdirs a directory every BOSS user already has).

There is no CHANGELOG in this repo, so the release-notes line for the same-path change
is an action item at release time, not a file change. Flagged in the body.

Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times.
detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in
this round's own code first.

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

* Address project announcement review feedback

* fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488)

* Batch validation: promote latest reviewed dev changes to main (#463)

* Fix #30: Resolve duplicate context menu label collisions

* Cleanup: Remove dead code for #91 and #93

* chore: Sanitize SubmitResult.Error at construction rather than at one render site

* Fix panel component lifecycle disposal

* fixes

* Guard panel lifecycle destruction failures

* fix(logging): sanitize secret RPC failures and crash-report errors

Three related gaps in what gets logged and shown when something fails:

- SecretService.kt had no BossLogger in any of its ten catch blocks (#145).
  When the organisation migration broke all four secret RPCs (#144), the
  only WARN in the log came from the calling plugin - the code that
  actually failed said nothing. Every catch now logs via
  logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized
  failure for both the log and the returned Result rather than sanitizing
  twice. RoleService and RoleCreationService were checked and already log
  correctly - SecretService was the only silent one.

- LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it
  appeared inside a URL - a bare hostname (exactly what
  UnknownHostException.getMessage() produces, i.e. every DNS failure, and
  what a proxy-connect failure looks like) passed through untouched (#109).
  Added a narrow hostname pattern: lowercase-only labels ending in a short
  explicit TLD/`.internal`/`.local` list, with a negative lookahead so a
  package path that happens to end in a real TLD word mid-FQN
  (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException)
  is not mistaken for one - caught by the existing realistic-stack-trace
  test after the first version of the pattern redacted a live Kotlin
  package name out of it.

- CrashReportService.SubmitResult.Error held a raw exception string,
  sanitized at exactly one render call site in CrashReportDialog (#110).
  The constructor is now private; SubmitResult.Error.of(...) is the only
  way to build one, and it sanitizes before the raw string can reach
  .message - a property of the type now, not something every future
  consumer (a copy button, a toast, a log line) has to remember on its own.
  Both construction sites (the service's two catch blocks, and the one in
  CrashReportDialog the issue names directly) now route through it.

Tests: new coverage for the ten now-logging SecretService paths (via
SupabaseWiringTest's existing sanitization-wiring guard, updated to
recognize the "sanitize once into a local, reuse for log and return" shape
SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for
the hostname fix (including the FQN/version-number false-positive guards),
and a new CrashReportServiceTest for the sanitize-at-construction guarantee.

* test(crash): extract construction tests from #404

Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately.

* chore(ipc): clarify advisory menu ID scope and fix formatting

* chore(ui): finish obsolete toolbar comment cleanup

* test(crash): adapt consolidated coverage and guard factory visibility

Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately.

* refactor(crash): consolidate construction sanitization in #311

Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively.

* test(panels): retain sibling lifecycle isolation coverage from #313

Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work.

* fix(panels): complete lifecycle cleanup across failure and window teardown

Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions.

* docs(ui): remove stale disabled top-bar feature list

* test(ipc): protect advisory menu item ID uniqueness

* fix(panels): keep store registration scoped to its window effect

Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged.

* fix(crash): address consolidated sanitization review notes

Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate.

* fix(logging): keep server failure payloads out of secret RPC logs

Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately.

* ci: build PR batches on dev

* Allow Claude diff reviews after approved fork builds (#413)

* fix(browser): preserve newer clipboard copies during plain-text paste (#316)

* fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205)

* fix(browser): make paste-without-formatting's restore identity-based, not text-based

Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers.

* fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount

* fix(browser): blank lines between FakeTransferable overrides per ktlint

* test(browser): retain clipboard restore scenarios from #408

* fix(browser): track clipboard ownership through AWT wrappers

---------

Co-authored-by: rehannayeem0786 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>

* fix(plugins): serialize concurrent first loads per class name (#324)

* fix(plugins): prevent duplicate class definitions during concurrent loads

* fix(plugins): make class loading parallel-safe

* test(plugins): pin concurrent loading lifecycle boundaries

Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes.

---------

Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>
Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412)

* fix: make BrowserHandle JavaScript execution cancellable

the previous synchronous JxBrowser call blocked the Main/EDT thread;
coroutine cancellation could not take effect while that call was blocking;
the implementation now uses JxBrowser’s asynchronous JavaScript callback API;
suspendCancellableCoroutine makes the Kotlin waiting side cancellable;
late callbacks after cancellation are safely ignored;
this does NOT terminate JavaScript already executing inside Chromium.

* fix: add robust native call lifecycle tracking for disposal

* test: verify native operation cleanup on synchronous failure

* fix: linearize browser disposal and pending operation tracking

* fix: resolve detekt violations in browser native tracking

* test: satisfy detekt failure simulation rule

* fix: close native operation disposal race

* fix(browser): drain an in-flight call before closing the browser

Issue #300 reports that executeJavaScript is a synchronous, non-
cancellation-aware native call, and a plugin's own withTimeoutOrNull
around it can only abandon the caller's wait - not the call itself -
which stays running on Main indefinitely and can race a concurrent
handle.dispose(), touching the same native object from two threads at
once.

The severe half of this was already fixed by BoundedBrowserCall: this
class confines every blocking round trip to one dedicated daemon thread
instead of Dispatchers.Main, so a wedged renderer no longer freezes the
app. What was not yet closed is the residual window this issue's core
report is actually about: shutdown() stopped new work but did not wait
for whatever was already running, so a caller could proceed straight to
browser.close() while a call from just before shutdown was still
finishing on the dedicated thread.

shutdown() now waits, bounded, for that in-flight call to drain before
returning. Costs nothing in the common case - awaitTermination returns
immediately once the one worker thread and its queue are both idle,
which is where an instance sits between calls - and only the bound in
the rare case something was genuinely still running.

Known, and disclosed in the KDoc rather than claimed away: this narrows
the race for the common case (a call that was always going to finish
quickly) rather than closing it. It cannot help when the in-flight call
is itself the wedge this class's own deadline exists for - waiting
unboundedly for a genuinely stuck call would reintroduce the exact freeze
BoundedBrowserCall exists to prevent, just moved from a plugin's await
into every caller's teardown path. There is no JxBrowser API on this
version able to interrupt a blocking round trip already inside the
native call, which is the whole reason the call is confined to its own
thread instead of cancelled - closing that window completely needs
JxBrowser's own cooperation, which the issue's own analysis already
concluded is unavailable.

Tests: shutdown waits for a fast in-flight call to finish before
returning, and does not wait past its own drain timeout for a wedged one
- the same before/after pair the rest of this test file already uses for
BoundedBrowserCall's deadline.

* fix(browser): defer native close until admitted calls drain

Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring.

* fix(browser): bound profile waits and clarify deferred cleanup ownership

Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit.

* fix(browser): schedule disposal after early UI teardown failure

* ci: create launcher directory before Claude native installation

* Revert "ci: create launcher directory before Claude native installation"

This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0.

* test: keep warmup registry ordering check on one event loop

* test(browser): normalize CRLF in disposal source wiring guards

---------

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

* feat(tabs): show a speaker glyph on tabs that are playing audio (#314)

* feat(tabs): show a speaker glyph on tabs that are playing audio (#308)

* fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush

1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister.

* fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures

- BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire.

* fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file

* fix(tabs): publish audio state by browser ownership and keep icon layout stable

---------

Co-authored-by: rehannayeem0786 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat: connect chrome density settings and keyboard controls (#305)

* feat: wire the chrome density scale into settings and small-screen defaults

ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on
main and every bar (BossTitleBar, BossTopBar, the main tab bar,
BossBottomBar) already read its height from BossChrome.dimens - but
LocalChromeDimens was never provided anywhere, so it silently resolved to
its staticCompositionLocalOf default (Comfortable) always. The scale was
built and unreachable.

This closes that gap:

- WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so
  an existing install's settings file - which has never heard of this key
  - decodes to exactly the chrome it already had; no settings-version bump
  needed).
- BossApp.kt now provides LocalChromeDimens from that field, host-only and
  outside BossAppCompositionLocals (plugins never draw host chrome).
- A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious),
  indexed in Settings search.
- A fresh install on a small screen (< 1000dp logical height, clearing the
  13" MacBook Air's ~931-956pt from #239's own measurements) now defaults
  to Compact and starts with the bottom bar off - the one bar this manager
  can still reclaim itself, since the side strips are already off by
  class default. Toolkit.getScreenSize() is read once, wrapped in
  runCatching (HeadlessException off a display must not break a fresh
  install), with a pure defaultDensityFor(screenHeightDp) so the decision
  is unit-testable without a display.

Addresses #239, scoped down from the full issue. Out of scope in this
pass, deliberately:

- The app does not yet report its own chrome budget as a measured
  percentage - the issue's own "state it, don't argue it" ask. Reachable
  later from the same ChromeDimens source, but needs a spot to render it.
- The title bar's 27dp "Boss Console" label row is untouched - collapsing
  or merging it with the tab bar is a separate, riskier layout change than
  a density scale.
- No regression intended to the existing per-bar show/hide switches or
  focus-mode edges: density only changes how much room a bar that is
  already on screen takes, never whether it is shown.

* fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line

Address review feedback on #305:

- Removed showBottomBar = density != ChromeDensity.COMPACT from
  getDefaultSettings(). ChromeMetricsTest already shows the shipped
  macOS defaults reach 93.3% of a 931dp window at Comfortable, so
  #239's >=90% bar is met without touching the status bar - and that
  bar's own KDoc argues it must stay on ("the only always-on readout").
  It was also reachable from the settings-load catch fallback, so a
  transient read error could silently drop the bar and have the next
  save persist the loss. Compact now only changes density, nothing else.

- Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's
  default MaxLineLength (120), which applies here since .editorconfig's
  140 is a ktlint-only setting and this line wasn't in the baseline.

* feat: add chrome density setting

* feat: add small-screen chrome defaults

* feat: add chrome density controls and small-screen defaults

* fix(ui): preserve lean density defaults and test density controls

* style: format multiline screen-height lookup

* fix(ui): separate density recovery and make editor chords opt-in

* style: wrap density default documentation

---------

Co-authored-by: AdityaK-iiita <adityakolate0070@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): stop host resource fallback after unload (#325)

* fix(plugins): stop host resource fallback after unload

* fix(plugins): preserve resource warning for a missing result

---------

Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* Handle closed browser frames during navigation callbacks (#400)

* Handle closed browser frames during navigation callbacks

* test(browser): retain closed-event guard after navigation integration

* fix(browser): preserve helpers on PID failure and recognize closed transports

* style(browser): wrap review diagnostics for detekt

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* refactor(window): pin the panel content alignment width (#398)

* fix(window): restore strict width constraints for nested panels

Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation.

* Cover panel content alignment across width changes

* Keep panel layout regression in the existing app test package

* Document panel alignment contract and pin filling content width

* Wrap the panel width assertion to satisfy detekt

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* #320 - Fix quick actions vanishing in short tab rail (#328)

* #320 - Fix quick actions vanishing in short tab rail

* Fix: detekt & ktlint checks

* Fix rail action budgets and reversible fallback wiring

* Fix resize test assertion import and update rail coverage note

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(crash): refine scroll boundary and bound error sanitization (#350)

* fix(crash): refine scroll boundary and bound error sanitization

* Avoid exposing a partial token at the crash message limit

* Document sanitizer input bound and verify expansion ordering

---------

Co-authored-by: Aishwary Anand <aishwary.cd22@bmsce.ac.in>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(logging): redact private hostnames (#346)

* fix(logging): redact private hostnames

* Handle private hostname punctuation and preserve diagnostic ports

* Format hostname punctuation regression assertion

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat: Opening an .html file should ask open as a file or webpage (#333)

* feat: Opening an .html file should ask open as a file or webpage

* Fix HTML prompt delivery and persisted preference ordering

* Cover HTML routing and correct review test fixtures

* Honor HTML navigation intent and release pending prompts on failure

* Isolate HTML prompt handling from the general dialog host

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugin-store): parse timestamps consistently in list and detail metadata (#379)

* Fix: Implement proper ISO timestamp parsing in PluginStoreClient

* fix(repository): implement robust ISO timestamp parsing for Supabase payloads

* style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught

* fix(plugin-store): implement plugin metadata timestamp parsing

PluginStoreClient.parseTimestamp was a stub that always returned 0L, so
every plugin fetched from the store showed "Last Updated"/"Published" as
the Unix epoch in the Toolbox UI regardless of what the server actually
sent (#337).

Parses the ISO-8601 timestamp (the shape the store sends, e.g.
2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant,
falling back to 0L for a blank, missing, or malformed string rather than
throwing - matching the field's own empty-string default for a response
that omits it. java.time rather than adding kotlinx-datetime as a new
dependency: this module already uses java.util.* directly in the same
commonMain source set (it has one real target, jvm("desktop")), so nothing
is gained by introducing a second time library for one function.

Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the
convention already established next to it in
PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset
timestamp, a blank string, and a malformed string.

* Fix timestamp normalization edge cases and verify both response mappings

* Use release publication dates and pin timezone-independent fallback

---------

Co-authored-by: Sanjay <sanjaysaini4423@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat(remote-ui): authenticate and expose remote UI surfaces (#348)

* feat(remote-ui): authenticate remote UI process identity

* feat(remote-ui): place authenticated remote UI surfaces

* fix(remote-ui): close ownership and placement lifecycle races

* test(remote-ui): expose placement recovery cases to JUnit

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat(dashboard): add shared read-only What's New release feed (#381)

* Add What's New release feed to dashboard

* Serialize update settings persistence

* Remove unused update settings import

* fix(updater): retain realtime refreshes during shared release fetches

* style(updater): format shared release fetch regression tests

* fix(updater): coalesce release refresh bursts and preserve visible history

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(language): consolidate host and out-of-process language-id tables (#358)

* fix(language): consolidate host and out-of-process language-id tables

EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor)
each hand-maintained their own extension-to-language-id table because
boss-app-editor - a plain JVM module compiled to a GraalVM native image -
could not depend on anything in composeApp. They disagreed: EditorServiceImpl
named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and
EditorServiceImpl was missing more than forty ids the other table had
(fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75).

Moves the table into a new plugin-platform/plugin-language-types module -
dependency-free by design, so it carries nothing extra into boss-app-editor's
native-image build - and has both EditorLanguages and EditorServiceImpl read
from it. This is the first time anything under modules/ depends on
plugin-platform/; verified compiling and passing tests before committing to
the approach.

EditorLanguages keeps its existing public API unchanged (same values for
every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest
needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry
and "plaintext" fallback as local additions on top of the shared table,
rather than folding them into it: proto isn't part of the boss-file-types.json
default-app-association surface the shared table backs, and "plaintext" is
this service's own gRPC default, not a value composeApp reads.

Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously
no tests existed for this class at all) - the latter pins the actual bug fix,
that detectLanguage("sh") now returns "bash" instead of "shell".

Out of scope, and disclosed rather than silently dropped: two of the five
duplicate tables the issue names - the editor-tab plugin's own
LanguageDetection and BossEditor's lexer registry - live in separate
repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in
plugin-icons is also not consolidated: it keys icon selection off the raw
filename to preserve distinctions a language id can't carry (package.json,
yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different
brand icons) and never computes a language id in the first place, so
routing it through this table would need a separate icon-selection redesign.

* fix(language): keep file-association drift checks on the shared table

* fix(language): apply shared filename rules to editor file opens

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(editor): contain write-local stack overflow and preserve fatal errors (#396)

* Make writeFileContentSafe actually safe: catch the Errors it was named for

`writeFileContentSafe` exists to return false rather than throw. It did not hold
that contract:

    } catch (e: Exception) {
        fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e)
        false
    }

StackOverflowError is an Error, not an Exception, so it went straight through -
out of writeFileContent, out of the editor_write_file MCP handler, and to the
caller as a bare StackOverflowError instead of the handler's own
"Write failed for <path>". The warn that would have named the file never ran
either.

That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two
independent reports of it, on unrelated content, and #27's complaint is precisely
the missing diagnosis: the error "gives no indication of which input caused it".

StackOverflowError and OutOfMemoryError are caught explicitly rather than
widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and
readFileContentSafe in this same file already treats OutOfMemoryError as a
reportable outcome. Throwable would also absorb LinkageError and ThreadDeath,
which are not this function's to swallow - there is a test asserting a
NoClassDefFoundError still propagates.

The failure log now names the path and the character count. Not the content:
these writes carry whatever the user is editing.

**This does not fix the write.** Whatever recurses is upstream of this function,
which is mkdirs plus writeText - I ruled out the plugin's handler, this write,
and the host's MCP argument parsing, and said so on the issue. What changes is
that the failure is reported honestly instead of escaping, which is where a
diagnosis can start.

The body moves to `guardedWrite` with an injectable write, because a test JVM
cannot be made to overflow the stack inside writeText on demand.

Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt.

Refs risa-labs-inc/boss-plugin-editor-tab#18, #27.

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

* docs(editor): clarify write guard scope and prior recursion fix

* fix(editor): preserve fatal heap errors in write guard

---------

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

* fix(state): apply version-checked JSON merge patches (#361)

* Fix: Implement proper ISO timestamp parsing in PluginStoreClient

* Feature: Implement JSON Merge Patch for plugin delta state synchronization

* fix(state): validate delta bases and exclude duplicate timestamp scope

* fix(state): reject malformed and deeply nested patches

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363)

Bumps `grpc` from 1.83.1 to 1.84.0.

Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

---
updated-dependencies:
- dependency-name: io.grpc:grpc-netty
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-protobuf
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-stub
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-services
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-util
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364)

Bumps `protobuf` from 4.36.0 to 4.36.1.

Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1

Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Commits](https://github.com/protocolbuffers/protobuf/commits)

---
updated-dependencies:
- dependency-name: com.google.protobuf:protobuf-kotlin
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.google.protobuf:protobuf-java
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): defer stale jar cleanup until restart (#340)

* fix(plugins): defer stale jar cleanup until restart

* fix(plugins): satisfy quality checks

* Preserve staged plugin artifacts when another plugin updates

* Require cleanup intent and retain artifacts with unordered manifest versions

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): defer fluck-browser hot-reload to a restart (#352)

* fix(plugins): defer stale jar cleanup until restart

* fix(plugins): defer fluck-browser hot-reload to a restart

Hot-reloading fluck-browser force-unloads the classloader that created its
JxBrowser native views. Every open tab - and every tab opened afterward,
since the factory that would recreate the view is gone too - draws an empty
box, and a second hot-reload does not recover it; only a full restart does.

HotReloadPolicy names plugins that must never be swapped in place, matching
the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at
every path that can force-unload a live plugin to apply an update: the
resetPluginInstances/doReloadPlugin path that actually reproduced this bug,
the menu-driven Reload action, the Toolbox update flow, and the store
installer. For a not-hot-reloadable plugin, the new jar is staged into
installed.json without touching the running instance or its open tabs, and
a status message tells the user to restart to apply it - the next cold
start loads the update with a fresh classloader.

Also fixes two call sites that asked "restart dependent plugins?" before
checking whether an unload was even going to happen, which produced a
confusing prompt for an unload that a deferred update was never going to
do.

* fix(plugins): satisfy quality checks

* Preserve staged plugin artifacts when another plugin updates

* Validate d…
* feat(search): index open tabs by their URL and file path (#606) (#648)

Global Search matched an open tab only against its title, and TabResult
hardcoded url = null / filePath = null on every hit. A browser tab whose
title never mentions its domain, or an editor tab someone wants to find
by path rather than filename, could not be found at all.

searchTabs now also scores a browser tab's FluckTabInfo.currentUrl and
an editor tab's EditorTabInfo.filePath, through the same substring-
containment gate (proseScore) searchRecentPages already uses for a
page's URL, since a fuzzy subsequence match over a whole URL or path is
close to meaningless. TabResult carries the real url/filePath instead
of null, and the tab row's subtitle now leads with the workspace name
(disambiguating identical URLs open in different workspaces, or across
windows) followed by the url/filePath/tabType, in place of the old
"tabType • workspaceName" line.

11 tests cover: URL-only and file-path-only matches, both fields
populated for the right tab type and left null for the other/a blank
path, a short query not matching a URL by fuzzy accident, title+URL
both matching taking the higher (title) score with title highlight
ranges preserved, live navigation changing which URL matches (and the
old URL no longer matching), directory-path matching without a
subsequence false-positive, and no-open-tabs contributing nothing
rather than failing.

* fix(browser): replace superseded capture requests (#509)

* Stop anon reaching schema public by inheritance, and scope identity to real orgs

The BOSS Supabase anon key is compiled into this repo, which is public. Anything
`anon` can execute is therefore executable by anyone. Verified on 2026-09-08:

  * get_encryption_key() returned the Vault master encryption key, unauthenticated.
    decrypt_text() was anon-callable beside it, making a decryption oracle over
    everything encrypt_text protects - user secrets and the QBO/Google broker
    credentials.
  * find_user_by_email() confirmed any address and returned its uuid.
  * list_shareable_recipients() returned 152 users WITH full email addresses to
    any self-registered account, and 82 @risalabs.ai addresses on a search.
  * arcade_leaderboard() and arcade_bs_standings() published the roster of
    everyone who had opened the Arcade, unauthenticated.

None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on
every new function, PUBLIC includes anon, and this project's default privileges
add anon on top - so a SECURITY DEFINER function is internet-callable from the
moment it is created. Both revokes are traps in mirror image: `from public`
leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and
ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses
the entry and new functions still get `=X`).

  20260908000000  event trigger: revoke PUBLIC+anon on every function created in
                  schema public. An explicit `grant ... to anon` after the create
                  still wins - anon access must be stated, never inherited.
  20260908010000  org_visible_users / org_is_vetted / user_display_name: ONE
                  definition of "may this account learn who that account is",
                  shared by the Arcade and poker rather than copied. Vetted means
                  a human approved the join, which excludes the catch-all `boss`
                  org every account joins on signup (153 members, 20 domains).
  20260908020000  route list_shareable_recipients through both. It was already
                  org-scoped, which is why it looked right; it accepted ANY
                  shared org.
  20260908030000  one-time sweep of the ~30 functions that already had the
                  inherited grant. The RBAC mutators fail closed
                  (is_user_admin(NULL) is false), so this is defence in depth,
                  not a patched escalation.

The rule is a SET, not a per-target predicate: as a per-row qual it is pushed
below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score
rows for 45 players, against 37ms.

supabase/audit/identity_disclosure_audit.sql is the standing check, because "we
fixed the leak" is not a durable claim. All four checks report HEALTHY.

Left deliberately anon-callable, each documented where it is kept: the plugin
store browse API, the three helpers called from RLS policies on anon-readable
tables, and custom_access_token_hook. Verified after the sweep that anonymous
plugin-store browse and signed-in RBAC reads both still work.

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

* Add the master-key rotation procedure, and rotate

get_encryption_key() was anon-callable, so the Vault master_encryption_key was
retrievable by anyone holding the project anon key - which ships compiled into
this public repo. Revoking access does not un-disclose a key, so it was rotated
on 2026-09-09 and the 184 rows encrypted under it re-encrypted.

Scope of the original exposure, stated precisely because the capability was
worse than the reach: decrypt_text() was anon-callable beside it, so an attacker
holding ciphertext from ANY channel - a backup, an export, a screenshot - could
decrypt it through the API without even needing the key. But no bulk read path
to that ciphertext existed: qbo_token_state and google_token_state have no
client grants at all, and all 178 secrets rows have org_id NULL, so the secrets
policy reduces to owner-only. This was a serious latent vulnerability - one
org-shared secret, one RLS slip, or anyone with backup access would have had
plaintext - rather than a confirmed bulk disclosure.

The script is kept because rotation is not a one-off. It is atomic (one DO
block), self-verifying (md5 of every row's plaintext captured before, re-derived
after through decrypt_text so the check covers both the swap and the data), and
a mismatch or row-count drift raises and rolls back, so a broken rotation cannot
commit. Verified after the run: the live key differs from the disclosed one,
184/184 rows decrypt under it, and the disclosed key opens 0 rows.

The old key is retained in the Vault under an explicit name. Do NOT delete it
while pre-rotation backups exist - they are encrypted under it.

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

* fix(db): close signed-in crypto access and verify visibility boundaries

* test(db): exercise repeated key rotation and exact anonymous ACLs

* test(db): grant fixture role membership on Supabase Postgres

* test(db): inline rotation source for isolated pgTAP mounts

* test(db): keep generated rotation SQL out of source control

* fix(db): reserve identity-taking store mutators for the edge service

* fix(db): serialize rotations without requiring direct Vault writes

* fix(db): address review gaps in system-org and rotation coverage

* test(db): verify core secrets across hex and broker-free rotations

* test(db): execute standing audit and missing-signature regression

* fix(db): enforce revoke postconditions and harden rotation verification

* test(db): enable recovery metadata in the authenticated fixture

* test(db): use the schema-supported authenticator type

* Address remaining database review quality findings

* Fix invoker view audit boolean parsing and test timestamp bounds

* Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift

Addresses the handoff's database-integration item and the substantiated findings
from the current-head review. Everything below was executed against the PR's own
Supabase preview branch, not asserted from reading.

**The blocker: rotation refused to run once #417 landed.** The guard was a
blanket "TOTP is installed, refuse everything". It is now an adapter. The stored
form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing
differs - so the column map gained an envelope prefix and rotates like the rest
once the prefix is stripped and re-applied. Three things this needed that reading
the diff would not have shown:

  * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a
    storage envelope'), so the re-encryption update is rejected outright. The
    reviewed trigger is now disabled for the update and restored after, inside
    the same transaction.
  * safe_decrypt_recovery_codes returns jsonb, not text, so verification through
    the real read path needed a cast. Both fingerprints now go through each
    column's OWN application read path rather than decrypt_text, which also
    removes the step-1/step-5 asymmetry raised as U5.
  * a step 0 pre-check reports rows that are already unreadable, instead of
    letting a safe_decrypt_* NULL surface at the end as "a row was missed".

The rejection contract is preserved, not removed: an unknown envelope version,
an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped
table each still refuse. All three are now regression-tested, along with the
recovery-code column that was previously never exercised (its fixture was
vacuous - create_secret writes no secret_metadata row without p_twofa_enabled).
Verified: three consecutive rotations, TOTP/recovery/password all intact, v1
envelope preserved, trigger re-enabled and still enforcing its own contract.

**CREATE EXTENSION was impossible.** The fail-closed guard aborted the first
routine of any extension installed into public: `create extension pgtap` failed
with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces
operators to disable it in order to install an extension is a guard that ends up
disabled. Extension-owned routines are now exempted with a warning, and the
exposure stays visible as advisory CHECK 1x rather than vanishing - it reports
pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers
are called from column DEFAULTs and CHECK constraints, which are evaluated with
the DML role's privileges. No extension owns a function in public on a clean
database, so no deployed behaviour changes.

**The repo was weaker than production.** find_user_by_email, get_session_status
and update_api_key_last_used were locked down on the live project on 2026-09-08
but never captured as a migration, so a fresh deploy re-opened them.
find_user_by_email is a user-enumeration oracle over auth.users and signup is
open. The standing audit caught this itself on the preview - CHECK 2 named it -
which is the drift that check exists for. Both remaining callers use
SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps
EXECUTE and neither path changes.

Also: the guard no longer hard-fails on a database without the Supabase roles (a
plain restore), and the vault fixtures no longer assume the key is absent (U3) -
both suites failed outright on any database that already had one. Retained-key
assertions measure this run's delta instead of an absolute count.

Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all
clean; the standing audit reports no failing gated check.

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

* Exercise the anonymous read path, not just the grant

The keep-list assertions only checked has_function_privilege for the seven
allowlisted signatures. That proves the grant, not the path: an RLS policy
expression is evaluated as the QUERYING role, so revoking a policy helper turns
an anonymous SELECT into 'permission denied for function ...' rather than an
empty result, and no privilege assertion can see it.

The seeding is the part that matters. A policy expression runs PER ROW, so on
an empty table it is never evaluated - and plugins and user_roles are both empty
on a fresh database, which made the first version of this probe pass no matter
what had been revoked. Caught by trying to make it fail.

Sensitivity is stated from measurement, not assumption: with a row present,
revoking can_view_plugin_row from anon does fail the probe and revoking
authorize does not, because permissive policies are ORed and short-circuit. A
helper in a policy that never has to be evaluated cannot be detected this way by
any test, so the comment says so rather than implying broader coverage.

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

* Make the gated audit checks and the test generator provable

Both halves of this are the same failure: a suite that is green while proving
nothing. I hit that twice writing the previous commits - a recovery-code fixture
over a secret_metadata row create_secret never wrote, and an anonymous-read
probe over an empty table whose RLS policy therefore never evaluated - so these
are not hypothetical.

Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY,
while every advisory check already had a fixture-based detection test. An
inverted `not exists`, a wrong role literal or a typo in the evtenabled test
would have left the gate permanently green. Each is now broken deliberately and
asserted to report it: a routine granted to anon AFTER creation (which is how
one gets past the event trigger), a revoked deliberate anonymous grant, a
disabled event trigger, and a crypto routine exposed to authenticated. They have
teeth by construction - the assertion is that the finding appears, so a broken
audit query fails them.

Finding 8. The generator gained --check, which regenerates in memory and
compares without writing, and orphan removal for a generated suite whose .sql.in
has been renamed or deleted - that file otherwise keeps being executed forever
with stale content. Orphans are identified by a provenance banner rather than a
hard-coded list, so a hand-written suite is never touched; verified that
explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by
making it fire: a hand edit and a planted orphan are both reported by --check,
and generate removes the orphan.

CI now runs generate and --check as their own step, so a preparation failure is
visible in the log instead of being attributed to the test run.

Preview: rotation 16/16, db access audit 15/15, visibility 56/56.

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

* Announce every project change, not only the plugin-initiated ones

Panels could come up empty and stay empty, and the reason was that the
startup restore told no one.

ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject,
which is the path a PLUGIN takes. The two callers that matter most bypass
it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject
directly when it restores the workspace, and BossTopBar's picker goes
through selectProjectInWindow. Neither published anything.

The ordering that produced the empty panel: DefaultPlugin is constructed in
a DisposableEffect keyed on registries and window state, not on
selectedProject, and it kicks off the plugin scan asynchronously while
BossAppScaffold composes and the sidebar builds panels immediately. The
restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON
reads. When those reads are slow the panel is built first, reads the ""
that WindowProjectState seeds itself with, and renders its no-project state.
The restore then lands, silently, and nothing remounts the cached component.

Publishing from the state itself rather than from one caller catches all
three. The bus is MutableSharedFlow(replay = 0), so a publish that never
happens cannot be recovered by a later subscriber - which is why this could
not stay per-caller.

The publish is removed from selectProject rather than added alongside the
collector; keeping both would double-fire on the plugin path.

previousPath is seeded from the current value so the StateFlow's replay of
it is not announced as a change. That seed is "" at startup, and telling
every plugin the project just became "" moments before the real restore
arrives is precisely the clear-yourself signal worth avoiding.

One collector per window: projectDataProvider is `by lazy` on DefaultPlugin,
which is per-window, so this cannot stack.

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

* Publish from the state's own callback, not from a lazily-built provider

The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl`
put the fix behind two `by lazy` initializers. `projectDataProvider` and
`applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only
host path that creates the bus at all. So the collector started only if some plugin
touched both before the workspace JSON reads finished - and "the reads are slow" is
the premise of the bug. Touched after the restore, the collector seeds to the
restored path and announces nothing; never touched, no caller is announced at all.

The announcement now hangs off the `ProjectSelectionCallback` that
`WindowProjectStateRegistry` installs when it builds the window's state, which
`BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is
the sole mutator of the selection and invokes that callback synchronously, so every
caller is covered with no coroutine, no scope and nothing lazy in the path. Being
synchronous also makes `previousProjectPath` a faithful history rather than
best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C.

The two registry entry points carried a copy of the wiring each; they now share one
`newState`, so the announcement cannot be installed on some windows and not others.

Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a
no-op whenever the bus had not been created, so on a build where no installed plugin
had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`,
not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered.
It now creates the bus instead of dropping. `getInstance` also re-checks the registry
on every call rather than only at creation, so an instance that exists while the
registry is empty can no longer strand the host's publisher.

Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and
`DefaultPlugin.dispose()` releases it through a named delegate, alongside
`logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a
`SupervisorJob`, matching `pluginScope`.

Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being
announced, the previous-path chain, the seeding rule, the same-path suppression,
both registry entry points, and the bus being created rather than dropping the event.
Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean.

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

* Round 2: honest bus wording, atomic previousPath, three more tests

Review (1/2) and (2/2).

1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no
   registered publisher means nobody holds the bus, so it has no subscribers and that
   first event still reaches no one. The comment, the test name and the PR body all
   claimed otherwise. Reworded to what it actually buys: the host stops being
   permanently silent while it waits for a plugin to touch `applicationEventBus`.

3. `previousPath` was check-then-act on a plain field, and
   `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to
   Main (verified: `KernelBootstrap` registers it, the bridge calls
   `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read
   and the write are one operation.

5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields
   are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the
   lock is uncontended after the first call and removes the question. The
   bus-before-publisher ordering is now written down, including why a publish landing
   in that window loses nothing.

6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is
   documented as defensive and test-reachable, and defaulted to "".

7. Three tests added:
   - a plugin-initiated selection is announced exactly once (catches both a silent
     regression to zero and an accidentally re-added publish);
   - the registry test now asserts the recent-projects half of the callback ran -
     deleting it from `newState` previously left every test green;
   - `dispose()` stops the recent-projects collector, which is the whole reason
     `projectDataProviderDelegate` became a named lazy.

   `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider
   collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so
   its collector silently never ran. Unconfined also makes the dispose test
   deterministic rather than a sleep.

Smaller: `SupervisorJob` comment describes it as future-proofing (there is one
collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of
disposing the provider is documented on `dispose()`.

Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but
it changes `events()` semantics for every subscriber and every event type on a bus
whose consumers are out of tree. The body now says the race is narrowed rather than
closed, and names the api-side `selectedProject` StateFlow as what closes it.

`:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean.

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

* Round 3: unconditional announcement, ordered publish, drop dead register

Review 3.

2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe"
   paragraph, since this widens *when* a filesystem path reaches every installed
   plugin.

3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only
   the `actual` had been corrected. Fixed, and it now states the part that IS still
   best-effort: replay = 0 means an unsubscribed event is gone either way.

4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw
   there skipped it AND left `previousPath` stale - every later selection reporting a
   previous path one step behind, silently, for the session. Now `try { recents }
   finally { announce }`: order preserved for anyone reading recents off the event,
   the announcement unconditional, the throw still propagating.

5. `getAndSet` closed the read-modify-write but not the publish order, so two
   concurrent selections could emit out of order. `previousPath` and the publish are
   now in one `synchronized` block (the AtomicReference is gone - the lock subsumes
   it). The KDoc says plainly what this still does not settle: `WindowProjectState`
   writes `_selectedProject.value` then calls the callback with no atomicity, so state
   and last event can still disagree. Closing that means locking upstream.

6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value`
   wholesale and could land mid-test, dropping a just-added path - only where
   `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A
   once-per-JVM settle in `@BeforeTest` puts that single file read before any test.
   The test paths stay deliberately non-existent, now with a comment saying why: the
   fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose
   directory is gone, so a real temp directory would leak where these self-heal.

7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`),
   and it overwrote an existing entry - handing back a state with a fresh announcer
   seeded to "" for a window that already had a project. `newState` stays; it is what
   keeps the wiring from drifting.

8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside
   the lock now, and leaving it reads as if a fast path survives.

Also: a test for the actual regression path, `applyWorkspace` announcing the project it
restores, rather than a comment claiming to imitate it.

Answering the grep you could not run: the only `projectChanges()` consumers in
boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign
`_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat
publish as a reload nudge. Recorded in the announcer comment.

Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not
closed"), naming the mirror case and the api-side StateFlow that closes it.

`:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean.

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

* Round 4: split the dispose out, stop tests writing the real ~/.boss

Review 4. Items 1 and 3 were the before-merge ones.

1. Verified: ProjectDataServiceBridge.watchRecentProjects collects
   provider.recentProjects, a StateFlow, so cancelling the provider's scope on
   window close leaves that gRPC stream open and silently frozen for every
   out-of-process plugin. Taking the first option and SPLITTING the dispose out.

   Note this reverses what round 1 asked for, and the justification changed rather
   than the opinion: once the announcement moved to the registry callback,
   ProjectDataProviderImpl went back to being exactly what it was before this PR, so
   the leak is pre-existing and unrelated to the subject. Closing it properly means
   the bridge reading ProjectState directly instead of a per-window provider, which
   is its own change. The reasoning is on the class so it is not re-fixed by accident.

3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict
   real ones from the developer's picker and no cleanup restores them.
   systemProperty("user.home", <build>/test-home) on the Test task. One run with it in
   place shows the existing suite was writing far more than recents to the real home -
   window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json,
   recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all
   land in test-home now. The settle is gone with it: no recent-projects.json in
   test-home means the wholesale reload never happens.

2. publishSystemEvent now refuses the bus != null && systemPublisher == null state
   instead of falling through into a bus that may not be the registry's, with one
   warning rather than one per event. The comment at the fallback is true as written.

4. Both stale comments fixed (one construction path, not two).

5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is
   testable - ProjectState is an object, so a hard-coded call cannot be made to fail.

6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys
   (ordering), what it costs (an inline subscriber runs holding the lock inside
   selectProject), and that the contended path is untested.

7. The serial-execution dependency is recorded in the test class KDoc.

Plus: multi-window isolation, and a test that makes the recents update throw.

One thing this round caused and fixed: removing dispose() left two
ProjectDataProviderImpl collectors alive on the test dispatcher. On a
TestCoroutineScheduler a live coroutine is something every later runTest waits on, and
the git classes timed out at 60s in full-suite runs while passing in isolation.
Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher():
same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a
real argument for the follow-up - without dispose() the provider cannot be constructed
in a test without leaking.

:composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean.

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

* Round 5: reset test-home per run, stop getInstance stealing a publisher

Review 5.

1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting
   there from the previous run. doFirst now deleteRecursively() before mkdirs(), so
   the home is fresh per run rather than merely private. Without it the redirect only
   helped the first run and ProjectState.init's load reintroduced the same race from
   the second. Verified by running the full suite twice back to back.

2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and
   `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest,
   BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests
   both, so getInstance cannot take a publisher away from whoever set it.

3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning -
   partialRegistryWarned is a one-shot process global, so "it warns" is unassertable
   after the first test to trip it. Uses a stub bus so the half-registry state is real.

4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant,
   previousPath advances before the publish), so it is not "fixed" later. The
   lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block.

5. Taken, including the optional ones:
   - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally
     discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching
     Throwable is the contract, and both are rethrown.
   - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main).
     This removes the global setMain AND the round-4 collector leak at the source
     rather than working around them, without reopening the DisposableProvider split.
   - Three more pins: a null window state announces nothing, applyWorkspace
     (restoreProject = false) announces nothing, unregister + getOrCreate restarts the
     chain at "".

Not taken, with reasons: jvmArgumentProviders (the current form works, is
config-cache-serialisable and passed CI on three OSes); widening the user.home redirect
to plugin-path-utils (its test only mkdirs a directory every BOSS user already has).

There is no CHANGELOG in this repo, so the release-notes line for the same-path change
is an action item at release time, not a file change. Flagged in the body.

Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times.
detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in
this round's own code first.

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

* Address project announcement review feedback

* fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488)

* Batch validation: promote latest reviewed dev changes to main (#463)

* Fix #30: Resolve duplicate context menu label collisions

* Cleanup: Remove dead code for #91 and #93

* chore: Sanitize SubmitResult.Error at construction rather than at one render site

* Fix panel component lifecycle disposal

* fixes

* Guard panel lifecycle destruction failures

* fix(logging): sanitize secret RPC failures and crash-report errors

Three related gaps in what gets logged and shown when something fails:

- SecretService.kt had no BossLogger in any of its ten catch blocks (#145).
  When the organisation migration broke all four secret RPCs (#144), the
  only WARN in the log came from the calling plugin - the code that
  actually failed said nothing. Every catch now logs via
  logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized
  failure for both the log and the returned Result rather than sanitizing
  twice. RoleService and RoleCreationService were checked and already log
  correctly - SecretService was the only silent one.

- LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it
  appeared inside a URL - a bare hostname (exactly what
  UnknownHostException.getMessage() produces, i.e. every DNS failure, and
  what a proxy-connect failure looks like) passed through untouched (#109).
  Added a narrow hostname pattern: lowercase-only labels ending in a short
  explicit TLD/`.internal`/`.local` list, with a negative lookahead so a
  package path that happens to end in a real TLD word mid-FQN
  (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException)
  is not mistaken for one - caught by the existing realistic-stack-trace
  test after the first version of the pattern redacted a live Kotlin
  package name out of it.

- CrashReportService.SubmitResult.Error held a raw exception string,
  sanitized at exactly one render call site in CrashReportDialog (#110).
  The constructor is now private; SubmitResult.Error.of(...) is the only
  way to build one, and it sanitizes before the raw string can reach
  .message - a property of the type now, not something every future
  consumer (a copy button, a toast, a log line) has to remember on its own.
  Both construction sites (the service's two catch blocks, and the one in
  CrashReportDialog the issue names directly) now route through it.

Tests: new coverage for the ten now-logging SecretService paths (via
SupabaseWiringTest's existing sanitization-wiring guard, updated to
recognize the "sanitize once into a local, reuse for log and return" shape
SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for
the hostname fix (including the FQN/version-number false-positive guards),
and a new CrashReportServiceTest for the sanitize-at-construction guarantee.

* test(crash): extract construction tests from #404

Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately.

* chore(ipc): clarify advisory menu ID scope and fix formatting

* chore(ui): finish obsolete toolbar comment cleanup

* test(crash): adapt consolidated coverage and guard factory visibility

Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately.

* refactor(crash): consolidate construction sanitization in #311

Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively.

* test(panels): retain sibling lifecycle isolation coverage from #313

Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work.

* fix(panels): complete lifecycle cleanup across failure and window teardown

Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions.

* docs(ui): remove stale disabled top-bar feature list

* test(ipc): protect advisory menu item ID uniqueness

* fix(panels): keep store registration scoped to its window effect

Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged.

* fix(crash): address consolidated sanitization review notes

Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate.

* fix(logging): keep server failure payloads out of secret RPC logs

Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately.

* ci: build PR batches on dev

* Allow Claude diff reviews after approved fork builds (#413)

* fix(browser): preserve newer clipboard copies during plain-text paste (#316)

* fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205)

* fix(browser): make paste-without-formatting's restore identity-based, not text-based

Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers.

* fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount

* fix(browser): blank lines between FakeTransferable overrides per ktlint

* test(browser): retain clipboard restore scenarios from #408

* fix(browser): track clipboard ownership through AWT wrappers

---------

Co-authored-by: rehannayeem0786 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>

* fix(plugins): serialize concurrent first loads per class name (#324)

* fix(plugins): prevent duplicate class definitions during concurrent loads

* fix(plugins): make class loading parallel-safe

* test(plugins): pin concurrent loading lifecycle boundaries

Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes.

---------

Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>
Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412)

* fix: make BrowserHandle JavaScript execution cancellable

the previous synchronous JxBrowser call blocked the Main/EDT thread;
coroutine cancellation could not take effect while that call was blocking;
the implementation now uses JxBrowser’s asynchronous JavaScript callback API;
suspendCancellableCoroutine makes the Kotlin waiting side cancellable;
late callbacks after cancellation are safely ignored;
this does NOT terminate JavaScript already executing inside Chromium.

* fix: add robust native call lifecycle tracking for disposal

* test: verify native operation cleanup on synchronous failure

* fix: linearize browser disposal and pending operation tracking

* fix: resolve detekt violations in browser native tracking

* test: satisfy detekt failure simulation rule

* fix: close native operation disposal race

* fix(browser): drain an in-flight call before closing the browser

Issue #300 reports that executeJavaScript is a synchronous, non-
cancellation-aware native call, and a plugin's own withTimeoutOrNull
around it can only abandon the caller's wait - not the call itself -
which stays running on Main indefinitely and can race a concurrent
handle.dispose(), touching the same native object from two threads at
once.

The severe half of this was already fixed by BoundedBrowserCall: this
class confines every blocking round trip to one dedicated daemon thread
instead of Dispatchers.Main, so a wedged renderer no longer freezes the
app. What was not yet closed is the residual window this issue's core
report is actually about: shutdown() stopped new work but did not wait
for whatever was already running, so a caller could proceed straight to
browser.close() while a call from just before shutdown was still
finishing on the dedicated thread.

shutdown() now waits, bounded, for that in-flight call to drain before
returning. Costs nothing in the common case - awaitTermination returns
immediately once the one worker thread and its queue are both idle,
which is where an instance sits between calls - and only the bound in
the rare case something was genuinely still running.

Known, and disclosed in the KDoc rather than claimed away: this narrows
the race for the common case (a call that was always going to finish
quickly) rather than closing it. It cannot help when the in-flight call
is itself the wedge this class's own deadline exists for - waiting
unboundedly for a genuinely stuck call would reintroduce the exact freeze
BoundedBrowserCall exists to prevent, just moved from a plugin's await
into every caller's teardown path. There is no JxBrowser API on this
version able to interrupt a blocking round trip already inside the
native call, which is the whole reason the call is confined to its own
thread instead of cancelled - closing that window completely needs
JxBrowser's own cooperation, which the issue's own analysis already
concluded is unavailable.

Tests: shutdown waits for a fast in-flight call to finish before
returning, and does not wait past its own drain timeout for a wedged one
- the same before/after pair the rest of this test file already uses for
BoundedBrowserCall's deadline.

* fix(browser): defer native close until admitted calls drain

Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring.

* fix(browser): bound profile waits and clarify deferred cleanup ownership

Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit.

* fix(browser): schedule disposal after early UI teardown failure

* ci: create launcher directory before Claude native installation

* Revert "ci: create launcher directory before Claude native installation"

This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0.

* test: keep warmup registry ordering check on one event loop

* test(browser): normalize CRLF in disposal source wiring guards

---------

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

* feat(tabs): show a speaker glyph on tabs that are playing audio (#314)

* feat(tabs): show a speaker glyph on tabs that are playing audio (#308)

* fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush

1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister.

* fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures

- BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire.

* fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file

* fix(tabs): publish audio state by browser ownership and keep icon layout stable

---------

Co-authored-by: rehannayeem0786 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat: connect chrome density settings and keyboard controls (#305)

* feat: wire the chrome density scale into settings and small-screen defaults

ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on
main and every bar (BossTitleBar, BossTopBar, the main tab bar,
BossBottomBar) already read its height from BossChrome.dimens - but
LocalChromeDimens was never provided anywhere, so it silently resolved to
its staticCompositionLocalOf default (Comfortable) always. The scale was
built and unreachable.

This closes that gap:

- WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so
  an existing install's settings file - which has never heard of this key
  - decodes to exactly the chrome it already had; no settings-version bump
  needed).
- BossApp.kt now provides LocalChromeDimens from that field, host-only and
  outside BossAppCompositionLocals (plugins never draw host chrome).
- A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious),
  indexed in Settings search.
- A fresh install on a small screen (< 1000dp logical height, clearing the
  13" MacBook Air's ~931-956pt from #239's own measurements) now defaults
  to Compact and starts with the bottom bar off - the one bar this manager
  can still reclaim itself, since the side strips are already off by
  class default. Toolkit.getScreenSize() is read once, wrapped in
  runCatching (HeadlessException off a display must not break a fresh
  install), with a pure defaultDensityFor(screenHeightDp) so the decision
  is unit-testable without a display.

Addresses #239, scoped down from the full issue. Out of scope in this
pass, deliberately:

- The app does not yet report its own chrome budget as a measured
  percentage - the issue's own "state it, don't argue it" ask. Reachable
  later from the same ChromeDimens source, but needs a spot to render it.
- The title bar's 27dp "Boss Console" label row is untouched - collapsing
  or merging it with the tab bar is a separate, riskier layout change than
  a density scale.
- No regression intended to the existing per-bar show/hide switches or
  focus-mode edges: density only changes how much room a bar that is
  already on screen takes, never whether it is shown.

* fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line

Address review feedback on #305:

- Removed showBottomBar = density != ChromeDensity.COMPACT from
  getDefaultSettings(). ChromeMetricsTest already shows the shipped
  macOS defaults reach 93.3% of a 931dp window at Comfortable, so
  #239's >=90% bar is met without touching the status bar - and that
  bar's own KDoc argues it must stay on ("the only always-on readout").
  It was also reachable from the settings-load catch fallback, so a
  transient read error could silently drop the bar and have the next
  save persist the loss. Compact now only changes density, nothing else.

- Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's
  default MaxLineLength (120), which applies here since .editorconfig's
  140 is a ktlint-only setting and this line wasn't in the baseline.

* feat: add chrome density setting

* feat: add small-screen chrome defaults

* feat: add chrome density controls and small-screen defaults

* fix(ui): preserve lean density defaults and test density controls

* style: format multiline screen-height lookup

* fix(ui): separate density recovery and make editor chords opt-in

* style: wrap density default documentation

---------

Co-authored-by: AdityaK-iiita <adityakolate0070@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): stop host resource fallback after unload (#325)

* fix(plugins): stop host resource fallback after unload

* fix(plugins): preserve resource warning for a missing result

---------

Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* Handle closed browser frames during navigation callbacks (#400)

* Handle closed browser frames during navigation callbacks

* test(browser): retain closed-event guard after navigation integration

* fix(browser): preserve helpers on PID failure and recognize closed transports

* style(browser): wrap review diagnostics for detekt

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* refactor(window): pin the panel content alignment width (#398)

* fix(window): restore strict width constraints for nested panels

Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation.

* Cover panel content alignment across width changes

* Keep panel layout regression in the existing app test package

* Document panel alignment contract and pin filling content width

* Wrap the panel width assertion to satisfy detekt

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* #320 - Fix quick actions vanishing in short tab rail (#328)

* #320 - Fix quick actions vanishing in short tab rail

* Fix: detekt & ktlint checks

* Fix rail action budgets and reversible fallback wiring

* Fix resize test assertion import and update rail coverage note

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(crash): refine scroll boundary and bound error sanitization (#350)

* fix(crash): refine scroll boundary and bound error sanitization

* Avoid exposing a partial token at the crash message limit

* Document sanitizer input bound and verify expansion ordering

---------

Co-authored-by: Aishwary Anand <aishwary.cd22@bmsce.ac.in>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(logging): redact private hostnames (#346)

* fix(logging): redact private hostnames

* Handle private hostname punctuation and preserve diagnostic ports

* Format hostname punctuation regression assertion

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat: Opening an .html file should ask open as a file or webpage (#333)

* feat: Opening an .html file should ask open as a file or webpage

* Fix HTML prompt delivery and persisted preference ordering

* Cover HTML routing and correct review test fixtures

* Honor HTML navigation intent and release pending prompts on failure

* Isolate HTML prompt handling from the general dialog host

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugin-store): parse timestamps consistently in list and detail metadata (#379)

* Fix: Implement proper ISO timestamp parsing in PluginStoreClient

* fix(repository): implement robust ISO timestamp parsing for Supabase payloads

* style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught

* fix(plugin-store): implement plugin metadata timestamp parsing

PluginStoreClient.parseTimestamp was a stub that always returned 0L, so
every plugin fetched from the store showed "Last Updated"/"Published" as
the Unix epoch in the Toolbox UI regardless of what the server actually
sent (#337).

Parses the ISO-8601 timestamp (the shape the store sends, e.g.
2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant,
falling back to 0L for a blank, missing, or malformed string rather than
throwing - matching the field's own empty-string default for a response
that omits it. java.time rather than adding kotlinx-datetime as a new
dependency: this module already uses java.util.* directly in the same
commonMain source set (it has one real target, jvm("desktop")), so nothing
is gained by introducing a second time library for one function.

Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the
convention already established next to it in
PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset
timestamp, a blank string, and a malformed string.

* Fix timestamp normalization edge cases and verify both response mappings

* Use release publication dates and pin timezone-independent fallback

---------

Co-authored-by: Sanjay <sanjaysaini4423@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat(remote-ui): authenticate and expose remote UI surfaces (#348)

* feat(remote-ui): authenticate remote UI process identity

* feat(remote-ui): place authenticated remote UI surfaces

* fix(remote-ui): close ownership and placement lifecycle races

* test(remote-ui): expose placement recovery cases to JUnit

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat(dashboard): add shared read-only What's New release feed (#381)

* Add What's New release feed to dashboard

* Serialize update settings persistence

* Remove unused update settings import

* fix(updater): retain realtime refreshes during shared release fetches

* style(updater): format shared release fetch regression tests

* fix(updater): coalesce release refresh bursts and preserve visible history

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(language): consolidate host and out-of-process language-id tables (#358)

* fix(language): consolidate host and out-of-process language-id tables

EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor)
each hand-maintained their own extension-to-language-id table because
boss-app-editor - a plain JVM module compiled to a GraalVM native image -
could not depend on anything in composeApp. They disagreed: EditorServiceImpl
named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and
EditorServiceImpl was missing more than forty ids the other table had
(fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75).

Moves the table into a new plugin-platform/plugin-language-types module -
dependency-free by design, so it carries nothing extra into boss-app-editor's
native-image build - and has both EditorLanguages and EditorServiceImpl read
from it. This is the first time anything under modules/ depends on
plugin-platform/; verified compiling and passing tests before committing to
the approach.

EditorLanguages keeps its existing public API unchanged (same values for
every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest
needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry
and "plaintext" fallback as local additions on top of the shared table,
rather than folding them into it: proto isn't part of the boss-file-types.json
default-app-association surface the shared table backs, and "plaintext" is
this service's own gRPC default, not a value composeApp reads.

Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously
no tests existed for this class at all) - the latter pins the actual bug fix,
that detectLanguage("sh") now returns "bash" instead of "shell".

Out of scope, and disclosed rather than silently dropped: two of the five
duplicate tables the issue names - the editor-tab plugin's own
LanguageDetection and BossEditor's lexer registry - live in separate
repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in
plugin-icons is also not consolidated: it keys icon selection off the raw
filename to preserve distinctions a language id can't carry (package.json,
yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different
brand icons) and never computes a language id in the first place, so
routing it through this table would need a separate icon-selection redesign.

* fix(language): keep file-association drift checks on the shared table

* fix(language): apply shared filename rules to editor file opens

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(editor): contain write-local stack overflow and preserve fatal errors (#396)

* Make writeFileContentSafe actually safe: catch the Errors it was named for

`writeFileContentSafe` exists to return false rather than throw. It did not hold
that contract:

    } catch (e: Exception) {
        fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e)
        false
    }

StackOverflowError is an Error, not an Exception, so it went straight through -
out of writeFileContent, out of the editor_write_file MCP handler, and to the
caller as a bare StackOverflowError instead of the handler's own
"Write failed for <path>". The warn that would have named the file never ran
either.

That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two
independent reports of it, on unrelated content, and #27's complaint is precisely
the missing diagnosis: the error "gives no indication of which input caused it".

StackOverflowError and OutOfMemoryError are caught explicitly rather than
widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and
readFileContentSafe in this same file already treats OutOfMemoryError as a
reportable outcome. Throwable would also absorb LinkageError and ThreadDeath,
which are not this function's to swallow - there is a test asserting a
NoClassDefFoundError still propagates.

The failure log now names the path and the character count. Not the content:
these writes carry whatever the user is editing.

**This does not fix the write.** Whatever recurses is upstream of this function,
which is mkdirs plus writeText - I ruled out the plugin's handler, this write,
and the host's MCP argument parsing, and said so on the issue. What changes is
that the failure is reported honestly instead of escaping, which is where a
diagnosis can start.

The body moves to `guardedWrite` with an injectable write, because a test JVM
cannot be made to overflow the stack inside writeText on demand.

Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt.

Refs risa-labs-inc/boss-plugin-editor-tab#18, #27.

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

* docs(editor): clarify write guard scope and prior recursion fix

* fix(editor): preserve fatal heap errors in write guard

---------

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

* fix(state): apply version-checked JSON merge patches (#361)

* Fix: Implement proper ISO timestamp parsing in PluginStoreClient

* Feature: Implement JSON Merge Patch for plugin delta state synchronization

* fix(state): validate delta bases and exclude duplicate timestamp scope

* fix(state): reject malformed and deeply nested patches

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363)

Bumps `grpc` from 1.83.1 to 1.84.0.

Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

---
updated-dependencies:
- dependency-name: io.grpc:grpc-netty
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-protobuf
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-stub
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-services
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-util
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364)

Bumps `protobuf` from 4.36.0 to 4.36.1.

Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1

Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Commits](https://github.com/protocolbuffers/protobuf/commits)

---
updated-dependencies:
- dependency-name: com.google.protobuf:protobuf-kotlin
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.google.protobuf:protobuf-java
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): defer stale jar cleanup until restart (#340)

* fix(plugins): defer stale jar cleanup until restart

* fix(plugins): satisfy quality checks

* Preserve staged plugin artifacts when another plugin updates

* Require cleanup intent and retain artifacts with unordered manifest versions

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): defer fluck-browser hot-reload to a restart (#352)

* fix(plugins): defer stale jar cleanup until restart

* fix(plugins): defer fluck-browser hot-reload to a restart

Hot-reloading fluck-browser force-unloads the classloader that created its
JxBrowser native views. Every open tab - and every tab opened afterward,
since the factory that would recreate the view is gone too - draws an empty
box, and a second hot-reload does not recover it; only a full restart does.

HotReloadPolicy names plugins that must never be swapped in place, matching
the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at
every path that can force-unload a live plugin to apply an update: the
resetPluginInstances/doReloadPlugin path that actually reproduced this bug,
the menu-driven Reload action, the Toolbox update flow, and the store
installer. For a not-hot-reloadable plugin, the new jar is staged into
installed.json without touching the running instance or its open tabs, and
a status message tells the user to restart to apply it - the next cold
start loads the update with a fresh classloader.

Also fixes two call sites that asked "restart dependent plugins?" before
checking whether an unload was even going to happen, which produced a
confusing prompt for an unload that a deferred update was never going to
do.

* fix(plugins): satisfy quality checks

* Preserve staged plugin artifacts when another plugin updates

* V…
* fix(overlays): honor RTL and fractional placement (#477)

* Stop anon reaching schema public by inheritance, and scope identity to real orgs

The BOSS Supabase anon key is compiled into this repo, which is public. Anything
`anon` can execute is therefore executable by anyone. Verified on 2026-09-08:

  * get_encryption_key() returned the Vault master encryption key, unauthenticated.
    decrypt_text() was anon-callable beside it, making a decryption oracle over
    everything encrypt_text protects - user secrets and the QBO/Google broker
    credentials.
  * find_user_by_email() confirmed any address and returned its uuid.
  * list_shareable_recipients() returned 152 users WITH full email addresses to
    any self-registered account, and 82 @risalabs.ai addresses on a search.
  * arcade_leaderboard() and arcade_bs_standings() published the roster of
    everyone who had opened the Arcade, unauthenticated.

None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on
every new function, PUBLIC includes anon, and this project's default privileges
add anon on top - so a SECURITY DEFINER function is internet-callable from the
moment it is created. Both revokes are traps in mirror image: `from public`
leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and
ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses
the entry and new functions still get `=X`).

  20260908000000  event trigger: revoke PUBLIC+anon on every function created in
                  schema public. An explicit `grant ... to anon` after the create
                  still wins - anon access must be stated, never inherited.
  20260908010000  org_visible_users / org_is_vetted / user_display_name: ONE
                  definition of "may this account learn who that account is",
                  shared by the Arcade and poker rather than copied. Vetted means
                  a human approved the join, which excludes the catch-all `boss`
                  org every account joins on signup (153 members, 20 domains).
  20260908020000  route list_shareable_recipients through both. It was already
                  org-scoped, which is why it looked right; it accepted ANY
                  shared org.
  20260908030000  one-time sweep of the ~30 functions that already had the
                  inherited grant. The RBAC mutators fail closed
                  (is_user_admin(NULL) is false), so this is defence in depth,
                  not a patched escalation.

The rule is a SET, not a per-target predicate: as a per-row qual it is pushed
below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score
rows for 45 players, against 37ms.

supabase/audit/identity_disclosure_audit.sql is the standing check, because "we
fixed the leak" is not a durable claim. All four checks report HEALTHY.

Left deliberately anon-callable, each documented where it is kept: the plugin
store browse API, the three helpers called from RLS policies on anon-readable
tables, and custom_access_token_hook. Verified after the sweep that anonymous
plugin-store browse and signed-in RBAC reads both still work.

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

* Add the master-key rotation procedure, and rotate

get_encryption_key() was anon-callable, so the Vault master_encryption_key was
retrievable by anyone holding the project anon key - which ships compiled into
this public repo. Revoking access does not un-disclose a key, so it was rotated
on 2026-09-09 and the 184 rows encrypted under it re-encrypted.

Scope of the original exposure, stated precisely because the capability was
worse than the reach: decrypt_text() was anon-callable beside it, so an attacker
holding ciphertext from ANY channel - a backup, an export, a screenshot - could
decrypt it through the API without even needing the key. But no bulk read path
to that ciphertext existed: qbo_token_state and google_token_state have no
client grants at all, and all 178 secrets rows have org_id NULL, so the secrets
policy reduces to owner-only. This was a serious latent vulnerability - one
org-shared secret, one RLS slip, or anyone with backup access would have had
plaintext - rather than a confirmed bulk disclosure.

The script is kept because rotation is not a one-off. It is atomic (one DO
block), self-verifying (md5 of every row's plaintext captured before, re-derived
after through decrypt_text so the check covers both the swap and the data), and
a mismatch or row-count drift raises and rolls back, so a broken rotation cannot
commit. Verified after the run: the live key differs from the disclosed one,
184/184 rows decrypt under it, and the disclosed key opens 0 rows.

The old key is retained in the Vault under an explicit name. Do NOT delete it
while pre-rotation backups exist - they are encrypted under it.

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

* fix(db): close signed-in crypto access and verify visibility boundaries

* test(db): exercise repeated key rotation and exact anonymous ACLs

* test(db): grant fixture role membership on Supabase Postgres

* test(db): inline rotation source for isolated pgTAP mounts

* test(db): keep generated rotation SQL out of source control

* fix(db): reserve identity-taking store mutators for the edge service

* fix(db): serialize rotations without requiring direct Vault writes

* fix(db): address review gaps in system-org and rotation coverage

* test(db): verify core secrets across hex and broker-free rotations

* test(db): execute standing audit and missing-signature regression

* fix(db): enforce revoke postconditions and harden rotation verification

* test(db): enable recovery metadata in the authenticated fixture

* test(db): use the schema-supported authenticator type

* Address remaining database review quality findings

* Fix invoker view audit boolean parsing and test timestamp bounds

* Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift

Addresses the handoff's database-integration item and the substantiated findings
from the current-head review. Everything below was executed against the PR's own
Supabase preview branch, not asserted from reading.

**The blocker: rotation refused to run once #417 landed.** The guard was a
blanket "TOTP is installed, refuse everything". It is now an adapter. The stored
form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing
differs - so the column map gained an envelope prefix and rotates like the rest
once the prefix is stripped and re-applied. Three things this needed that reading
the diff would not have shown:

  * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a
    storage envelope'), so the re-encryption update is rejected outright. The
    reviewed trigger is now disabled for the update and restored after, inside
    the same transaction.
  * safe_decrypt_recovery_codes returns jsonb, not text, so verification through
    the real read path needed a cast. Both fingerprints now go through each
    column's OWN application read path rather than decrypt_text, which also
    removes the step-1/step-5 asymmetry raised as U5.
  * a step 0 pre-check reports rows that are already unreadable, instead of
    letting a safe_decrypt_* NULL surface at the end as "a row was missed".

The rejection contract is preserved, not removed: an unknown envelope version,
an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped
table each still refuse. All three are now regression-tested, along with the
recovery-code column that was previously never exercised (its fixture was
vacuous - create_secret writes no secret_metadata row without p_twofa_enabled).
Verified: three consecutive rotations, TOTP/recovery/password all intact, v1
envelope preserved, trigger re-enabled and still enforcing its own contract.

**CREATE EXTENSION was impossible.** The fail-closed guard aborted the first
routine of any extension installed into public: `create extension pgtap` failed
with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces
operators to disable it in order to install an extension is a guard that ends up
disabled. Extension-owned routines are now exempted with a warning, and the
exposure stays visible as advisory CHECK 1x rather than vanishing - it reports
pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers
are called from column DEFAULTs and CHECK constraints, which are evaluated with
the DML role's privileges. No extension owns a function in public on a clean
database, so no deployed behaviour changes.

**The repo was weaker than production.** find_user_by_email, get_session_status
and update_api_key_last_used were locked down on the live project on 2026-09-08
but never captured as a migration, so a fresh deploy re-opened them.
find_user_by_email is a user-enumeration oracle over auth.users and signup is
open. The standing audit caught this itself on the preview - CHECK 2 named it -
which is the drift that check exists for. Both remaining callers use
SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps
EXECUTE and neither path changes.

Also: the guard no longer hard-fails on a database without the Supabase roles (a
plain restore), and the vault fixtures no longer assume the key is absent (U3) -
both suites failed outright on any database that already had one. Retained-key
assertions measure this run's delta instead of an absolute count.

Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all
clean; the standing audit reports no failing gated check.

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

* Exercise the anonymous read path, not just the grant

The keep-list assertions only checked has_function_privilege for the seven
allowlisted signatures. That proves the grant, not the path: an RLS policy
expression is evaluated as the QUERYING role, so revoking a policy helper turns
an anonymous SELECT into 'permission denied for function ...' rather than an
empty result, and no privilege assertion can see it.

The seeding is the part that matters. A policy expression runs PER ROW, so on
an empty table it is never evaluated - and plugins and user_roles are both empty
on a fresh database, which made the first version of this probe pass no matter
what had been revoked. Caught by trying to make it fail.

Sensitivity is stated from measurement, not assumption: with a row present,
revoking can_view_plugin_row from anon does fail the probe and revoking
authorize does not, because permissive policies are ORed and short-circuit. A
helper in a policy that never has to be evaluated cannot be detected this way by
any test, so the comment says so rather than implying broader coverage.

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

* Make the gated audit checks and the test generator provable

Both halves of this are the same failure: a suite that is green while proving
nothing. I hit that twice writing the previous commits - a recovery-code fixture
over a secret_metadata row create_secret never wrote, and an anonymous-read
probe over an empty table whose RLS policy therefore never evaluated - so these
are not hypothetical.

Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY,
while every advisory check already had a fixture-based detection test. An
inverted `not exists`, a wrong role literal or a typo in the evtenabled test
would have left the gate permanently green. Each is now broken deliberately and
asserted to report it: a routine granted to anon AFTER creation (which is how
one gets past the event trigger), a revoked deliberate anonymous grant, a
disabled event trigger, and a crypto routine exposed to authenticated. They have
teeth by construction - the assertion is that the finding appears, so a broken
audit query fails them.

Finding 8. The generator gained --check, which regenerates in memory and
compares without writing, and orphan removal for a generated suite whose .sql.in
has been renamed or deleted - that file otherwise keeps being executed forever
with stale content. Orphans are identified by a provenance banner rather than a
hard-coded list, so a hand-written suite is never touched; verified that
explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by
making it fire: a hand edit and a planted orphan are both reported by --check,
and generate removes the orphan.

CI now runs generate and --check as their own step, so a preparation failure is
visible in the log instead of being attributed to the test run.

Preview: rotation 16/16, db access audit 15/15, visibility 56/56.

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

* Announce every project change, not only the plugin-initiated ones

Panels could come up empty and stay empty, and the reason was that the
startup restore told no one.

ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject,
which is the path a PLUGIN takes. The two callers that matter most bypass
it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject
directly when it restores the workspace, and BossTopBar's picker goes
through selectProjectInWindow. Neither published anything.

The ordering that produced the empty panel: DefaultPlugin is constructed in
a DisposableEffect keyed on registries and window state, not on
selectedProject, and it kicks off the plugin scan asynchronously while
BossAppScaffold composes and the sidebar builds panels immediately. The
restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON
reads. When those reads are slow the panel is built first, reads the ""
that WindowProjectState seeds itself with, and renders its no-project state.
The restore then lands, silently, and nothing remounts the cached component.

Publishing from the state itself rather than from one caller catches all
three. The bus is MutableSharedFlow(replay = 0), so a publish that never
happens cannot be recovered by a later subscriber - which is why this could
not stay per-caller.

The publish is removed from selectProject rather than added alongside the
collector; keeping both would double-fire on the plugin path.

previousPath is seeded from the current value so the StateFlow's replay of
it is not announced as a change. That seed is "" at startup, and telling
every plugin the project just became "" moments before the real restore
arrives is precisely the clear-yourself signal worth avoiding.

One collector per window: projectDataProvider is `by lazy` on DefaultPlugin,
which is per-window, so this cannot stack.

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

* Publish from the state's own callback, not from a lazily-built provider

The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl`
put the fix behind two `by lazy` initializers. `projectDataProvider` and
`applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only
host path that creates the bus at all. So the collector started only if some plugin
touched both before the workspace JSON reads finished - and "the reads are slow" is
the premise of the bug. Touched after the restore, the collector seeds to the
restored path and announces nothing; never touched, no caller is announced at all.

The announcement now hangs off the `ProjectSelectionCallback` that
`WindowProjectStateRegistry` installs when it builds the window's state, which
`BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is
the sole mutator of the selection and invokes that callback synchronously, so every
caller is covered with no coroutine, no scope and nothing lazy in the path. Being
synchronous also makes `previousProjectPath` a faithful history rather than
best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C.

The two registry entry points carried a copy of the wiring each; they now share one
`newState`, so the announcement cannot be installed on some windows and not others.

Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a
no-op whenever the bus had not been created, so on a build where no installed plugin
had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`,
not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered.
It now creates the bus instead of dropping. `getInstance` also re-checks the registry
on every call rather than only at creation, so an instance that exists while the
registry is empty can no longer strand the host's publisher.

Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and
`DefaultPlugin.dispose()` releases it through a named delegate, alongside
`logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a
`SupervisorJob`, matching `pluginScope`.

Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being
announced, the previous-path chain, the seeding rule, the same-path suppression,
both registry entry points, and the bus being created rather than dropping the event.
Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean.

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

* Round 2: honest bus wording, atomic previousPath, three more tests

Review (1/2) and (2/2).

1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no
   registered publisher means nobody holds the bus, so it has no subscribers and that
   first event still reaches no one. The comment, the test name and the PR body all
   claimed otherwise. Reworded to what it actually buys: the host stops being
   permanently silent while it waits for a plugin to touch `applicationEventBus`.

3. `previousPath` was check-then-act on a plain field, and
   `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to
   Main (verified: `KernelBootstrap` registers it, the bridge calls
   `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read
   and the write are one operation.

5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields
   are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the
   lock is uncontended after the first call and removes the question. The
   bus-before-publisher ordering is now written down, including why a publish landing
   in that window loses nothing.

6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is
   documented as defensive and test-reachable, and defaulted to "".

7. Three tests added:
   - a plugin-initiated selection is announced exactly once (catches both a silent
     regression to zero and an accidentally re-added publish);
   - the registry test now asserts the recent-projects half of the callback ran -
     deleting it from `newState` previously left every test green;
   - `dispose()` stops the recent-projects collector, which is the whole reason
     `projectDataProviderDelegate` became a named lazy.

   `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider
   collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so
   its collector silently never ran. Unconfined also makes the dispose test
   deterministic rather than a sleep.

Smaller: `SupervisorJob` comment describes it as future-proofing (there is one
collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of
disposing the provider is documented on `dispose()`.

Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but
it changes `events()` semantics for every subscriber and every event type on a bus
whose consumers are out of tree. The body now says the race is narrowed rather than
closed, and names the api-side `selectedProject` StateFlow as what closes it.

`:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean.

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

* Round 3: unconditional announcement, ordered publish, drop dead register

Review 3.

2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe"
   paragraph, since this widens *when* a filesystem path reaches every installed
   plugin.

3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only
   the `actual` had been corrected. Fixed, and it now states the part that IS still
   best-effort: replay = 0 means an unsubscribed event is gone either way.

4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw
   there skipped it AND left `previousPath` stale - every later selection reporting a
   previous path one step behind, silently, for the session. Now `try { recents }
   finally { announce }`: order preserved for anyone reading recents off the event,
   the announcement unconditional, the throw still propagating.

5. `getAndSet` closed the read-modify-write but not the publish order, so two
   concurrent selections could emit out of order. `previousPath` and the publish are
   now in one `synchronized` block (the AtomicReference is gone - the lock subsumes
   it). The KDoc says plainly what this still does not settle: `WindowProjectState`
   writes `_selectedProject.value` then calls the callback with no atomicity, so state
   and last event can still disagree. Closing that means locking upstream.

6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value`
   wholesale and could land mid-test, dropping a just-added path - only where
   `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A
   once-per-JVM settle in `@BeforeTest` puts that single file read before any test.
   The test paths stay deliberately non-existent, now with a comment saying why: the
   fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose
   directory is gone, so a real temp directory would leak where these self-heal.

7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`),
   and it overwrote an existing entry - handing back a state with a fresh announcer
   seeded to "" for a window that already had a project. `newState` stays; it is what
   keeps the wiring from drifting.

8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside
   the lock now, and leaving it reads as if a fast path survives.

Also: a test for the actual regression path, `applyWorkspace` announcing the project it
restores, rather than a comment claiming to imitate it.

Answering the grep you could not run: the only `projectChanges()` consumers in
boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign
`_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat
publish as a reload nudge. Recorded in the announcer comment.

Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not
closed"), naming the mirror case and the api-side StateFlow that closes it.

`:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean.

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

* Round 4: split the dispose out, stop tests writing the real ~/.boss

Review 4. Items 1 and 3 were the before-merge ones.

1. Verified: ProjectDataServiceBridge.watchRecentProjects collects
   provider.recentProjects, a StateFlow, so cancelling the provider's scope on
   window close leaves that gRPC stream open and silently frozen for every
   out-of-process plugin. Taking the first option and SPLITTING the dispose out.

   Note this reverses what round 1 asked for, and the justification changed rather
   than the opinion: once the announcement moved to the registry callback,
   ProjectDataProviderImpl went back to being exactly what it was before this PR, so
   the leak is pre-existing and unrelated to the subject. Closing it properly means
   the bridge reading ProjectState directly instead of a per-window provider, which
   is its own change. The reasoning is on the class so it is not re-fixed by accident.

3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict
   real ones from the developer's picker and no cleanup restores them.
   systemProperty("user.home", <build>/test-home) on the Test task. One run with it in
   place shows the existing suite was writing far more than recents to the real home -
   window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json,
   recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all
   land in test-home now. The settle is gone with it: no recent-projects.json in
   test-home means the wholesale reload never happens.

2. publishSystemEvent now refuses the bus != null && systemPublisher == null state
   instead of falling through into a bus that may not be the registry's, with one
   warning rather than one per event. The comment at the fallback is true as written.

4. Both stale comments fixed (one construction path, not two).

5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is
   testable - ProjectState is an object, so a hard-coded call cannot be made to fail.

6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys
   (ordering), what it costs (an inline subscriber runs holding the lock inside
   selectProject), and that the contended path is untested.

7. The serial-execution dependency is recorded in the test class KDoc.

Plus: multi-window isolation, and a test that makes the recents update throw.

One thing this round caused and fixed: removing dispose() left two
ProjectDataProviderImpl collectors alive on the test dispatcher. On a
TestCoroutineScheduler a live coroutine is something every later runTest waits on, and
the git classes timed out at 60s in full-suite runs while passing in isolation.
Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher():
same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a
real argument for the follow-up - without dispose() the provider cannot be constructed
in a test without leaking.

:composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean.

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

* Round 5: reset test-home per run, stop getInstance stealing a publisher

Review 5.

1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting
   there from the previous run. doFirst now deleteRecursively() before mkdirs(), so
   the home is fresh per run rather than merely private. Without it the redirect only
   helped the first run and ProjectState.init's load reintroduced the same race from
   the second. Verified by running the full suite twice back to back.

2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and
   `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest,
   BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests
   both, so getInstance cannot take a publisher away from whoever set it.

3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning -
   partialRegistryWarned is a one-shot process global, so "it warns" is unassertable
   after the first test to trip it. Uses a stub bus so the half-registry state is real.

4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant,
   previousPath advances before the publish), so it is not "fixed" later. The
   lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block.

5. Taken, including the optional ones:
   - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally
     discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching
     Throwable is the contract, and both are rethrown.
   - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main).
     This removes the global setMain AND the round-4 collector leak at the source
     rather than working around them, without reopening the DisposableProvider split.
   - Three more pins: a null window state announces nothing, applyWorkspace
     (restoreProject = false) announces nothing, unregister + getOrCreate restarts the
     chain at "".

Not taken, with reasons: jvmArgumentProviders (the current form works, is
config-cache-serialisable and passed CI on three OSes); widening the user.home redirect
to plugin-path-utils (its test only mkdirs a directory every BOSS user already has).

There is no CHANGELOG in this repo, so the release-notes line for the same-path change
is an action item at release time, not a file change. Flagged in the body.

Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times.
detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in
this round's own code first.

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

* Address project announcement review feedback

* fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488)

* Batch validation: promote latest reviewed dev changes to main (#463)

* Fix #30: Resolve duplicate context menu label collisions

* Cleanup: Remove dead code for #91 and #93

* chore: Sanitize SubmitResult.Error at construction rather than at one render site

* Fix panel component lifecycle disposal

* fixes

* Guard panel lifecycle destruction failures

* fix(logging): sanitize secret RPC failures and crash-report errors

Three related gaps in what gets logged and shown when something fails:

- SecretService.kt had no BossLogger in any of its ten catch blocks (#145).
  When the organisation migration broke all four secret RPCs (#144), the
  only WARN in the log came from the calling plugin - the code that
  actually failed said nothing. Every catch now logs via
  logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized
  failure for both the log and the returned Result rather than sanitizing
  twice. RoleService and RoleCreationService were checked and already log
  correctly - SecretService was the only silent one.

- LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it
  appeared inside a URL - a bare hostname (exactly what
  UnknownHostException.getMessage() produces, i.e. every DNS failure, and
  what a proxy-connect failure looks like) passed through untouched (#109).
  Added a narrow hostname pattern: lowercase-only labels ending in a short
  explicit TLD/`.internal`/`.local` list, with a negative lookahead so a
  package path that happens to end in a real TLD word mid-FQN
  (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException)
  is not mistaken for one - caught by the existing realistic-stack-trace
  test after the first version of the pattern redacted a live Kotlin
  package name out of it.

- CrashReportService.SubmitResult.Error held a raw exception string,
  sanitized at exactly one render call site in CrashReportDialog (#110).
  The constructor is now private; SubmitResult.Error.of(...) is the only
  way to build one, and it sanitizes before the raw string can reach
  .message - a property of the type now, not something every future
  consumer (a copy button, a toast, a log line) has to remember on its own.
  Both construction sites (the service's two catch blocks, and the one in
  CrashReportDialog the issue names directly) now route through it.

Tests: new coverage for the ten now-logging SecretService paths (via
SupabaseWiringTest's existing sanitization-wiring guard, updated to
recognize the "sanitize once into a local, reuse for log and return" shape
SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for
the hostname fix (including the FQN/version-number false-positive guards),
and a new CrashReportServiceTest for the sanitize-at-construction guarantee.

* test(crash): extract construction tests from #404

Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately.

* chore(ipc): clarify advisory menu ID scope and fix formatting

* chore(ui): finish obsolete toolbar comment cleanup

* test(crash): adapt consolidated coverage and guard factory visibility

Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately.

* refactor(crash): consolidate construction sanitization in #311

Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively.

* test(panels): retain sibling lifecycle isolation coverage from #313

Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work.

* fix(panels): complete lifecycle cleanup across failure and window teardown

Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions.

* docs(ui): remove stale disabled top-bar feature list

* test(ipc): protect advisory menu item ID uniqueness

* fix(panels): keep store registration scoped to its window effect

Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged.

* fix(crash): address consolidated sanitization review notes

Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate.

* fix(logging): keep server failure payloads out of secret RPC logs

Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately.

* ci: build PR batches on dev

* Allow Claude diff reviews after approved fork builds (#413)

* fix(browser): preserve newer clipboard copies during plain-text paste (#316)

* fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205)

* fix(browser): make paste-without-formatting's restore identity-based, not text-based

Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers.

* fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount

* fix(browser): blank lines between FakeTransferable overrides per ktlint

* test(browser): retain clipboard restore scenarios from #408

* fix(browser): track clipboard ownership through AWT wrappers

---------

Co-authored-by: rehannayeem0786 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>

* fix(plugins): serialize concurrent first loads per class name (#324)

* fix(plugins): prevent duplicate class definitions during concurrent loads

* fix(plugins): make class loading parallel-safe

* test(plugins): pin concurrent loading lifecycle boundaries

Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes.

---------

Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>
Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412)

* fix: make BrowserHandle JavaScript execution cancellable

the previous synchronous JxBrowser call blocked the Main/EDT thread;
coroutine cancellation could not take effect while that call was blocking;
the implementation now uses JxBrowser’s asynchronous JavaScript callback API;
suspendCancellableCoroutine makes the Kotlin waiting side cancellable;
late callbacks after cancellation are safely ignored;
this does NOT terminate JavaScript already executing inside Chromium.

* fix: add robust native call lifecycle tracking for disposal

* test: verify native operation cleanup on synchronous failure

* fix: linearize browser disposal and pending operation tracking

* fix: resolve detekt violations in browser native tracking

* test: satisfy detekt failure simulation rule

* fix: close native operation disposal race

* fix(browser): drain an in-flight call before closing the browser

Issue #300 reports that executeJavaScript is a synchronous, non-
cancellation-aware native call, and a plugin's own withTimeoutOrNull
around it can only abandon the caller's wait - not the call itself -
which stays running on Main indefinitely and can race a concurrent
handle.dispose(), touching the same native object from two threads at
once.

The severe half of this was already fixed by BoundedBrowserCall: this
class confines every blocking round trip to one dedicated daemon thread
instead of Dispatchers.Main, so a wedged renderer no longer freezes the
app. What was not yet closed is the residual window this issue's core
report is actually about: shutdown() stopped new work but did not wait
for whatever was already running, so a caller could proceed straight to
browser.close() while a call from just before shutdown was still
finishing on the dedicated thread.

shutdown() now waits, bounded, for that in-flight call to drain before
returning. Costs nothing in the common case - awaitTermination returns
immediately once the one worker thread and its queue are both idle,
which is where an instance sits between calls - and only the bound in
the rare case something was genuinely still running.

Known, and disclosed in the KDoc rather than claimed away: this narrows
the race for the common case (a call that was always going to finish
quickly) rather than closing it. It cannot help when the in-flight call
is itself the wedge this class's own deadline exists for - waiting
unboundedly for a genuinely stuck call would reintroduce the exact freeze
BoundedBrowserCall exists to prevent, just moved from a plugin's await
into every caller's teardown path. There is no JxBrowser API on this
version able to interrupt a blocking round trip already inside the
native call, which is the whole reason the call is confined to its own
thread instead of cancelled - closing that window completely needs
JxBrowser's own cooperation, which the issue's own analysis already
concluded is unavailable.

Tests: shutdown waits for a fast in-flight call to finish before
returning, and does not wait past its own drain timeout for a wedged one
- the same before/after pair the rest of this test file already uses for
BoundedBrowserCall's deadline.

* fix(browser): defer native close until admitted calls drain

Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring.

* fix(browser): bound profile waits and clarify deferred cleanup ownership

Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit.

* fix(browser): schedule disposal after early UI teardown failure

* ci: create launcher directory before Claude native installation

* Revert "ci: create launcher directory before Claude native installation"

This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0.

* test: keep warmup registry ordering check on one event loop

* test(browser): normalize CRLF in disposal source wiring guards

---------

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

* feat(tabs): show a speaker glyph on tabs that are playing audio (#314)

* feat(tabs): show a speaker glyph on tabs that are playing audio (#308)

* fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush

1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister.

* fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures

- BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire.

* fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file

* fix(tabs): publish audio state by browser ownership and keep icon layout stable

---------

Co-authored-by: rehannayeem0786 <rehannayeem0786@github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat: connect chrome density settings and keyboard controls (#305)

* feat: wire the chrome density scale into settings and small-screen defaults

ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on
main and every bar (BossTitleBar, BossTopBar, the main tab bar,
BossBottomBar) already read its height from BossChrome.dimens - but
LocalChromeDimens was never provided anywhere, so it silently resolved to
its staticCompositionLocalOf default (Comfortable) always. The scale was
built and unreachable.

This closes that gap:

- WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so
  an existing install's settings file - which has never heard of this key
  - decodes to exactly the chrome it already had; no settings-version bump
  needed).
- BossApp.kt now provides LocalChromeDimens from that field, host-only and
  outside BossAppCompositionLocals (plugins never draw host chrome).
- A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious),
  indexed in Settings search.
- A fresh install on a small screen (< 1000dp logical height, clearing the
  13" MacBook Air's ~931-956pt from #239's own measurements) now defaults
  to Compact and starts with the bottom bar off - the one bar this manager
  can still reclaim itself, since the side strips are already off by
  class default. Toolkit.getScreenSize() is read once, wrapped in
  runCatching (HeadlessException off a display must not break a fresh
  install), with a pure defaultDensityFor(screenHeightDp) so the decision
  is unit-testable without a display.

Addresses #239, scoped down from the full issue. Out of scope in this
pass, deliberately:

- The app does not yet report its own chrome budget as a measured
  percentage - the issue's own "state it, don't argue it" ask. Reachable
  later from the same ChromeDimens source, but needs a spot to render it.
- The title bar's 27dp "Boss Console" label row is untouched - collapsing
  or merging it with the tab bar is a separate, riskier layout change than
  a density scale.
- No regression intended to the existing per-bar show/hide switches or
  focus-mode edges: density only changes how much room a bar that is
  already on screen takes, never whether it is shown.

* fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line

Address review feedback on #305:

- Removed showBottomBar = density != ChromeDensity.COMPACT from
  getDefaultSettings(). ChromeMetricsTest already shows the shipped
  macOS defaults reach 93.3% of a 931dp window at Comfortable, so
  #239's >=90% bar is met without touching the status bar - and that
  bar's own KDoc argues it must stay on ("the only always-on readout").
  It was also reachable from the settings-load catch fallback, so a
  transient read error could silently drop the bar and have the next
  save persist the loss. Compact now only changes density, nothing else.

- Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's
  default MaxLineLength (120), which applies here since .editorconfig's
  140 is a ktlint-only setting and this line wasn't in the baseline.

* feat: add chrome density setting

* feat: add small-screen chrome defaults

* feat: add chrome density controls and small-screen defaults

* fix(ui): preserve lean density defaults and test density controls

* style: format multiline screen-height lookup

* fix(ui): separate density recovery and make editor chords opt-in

* style: wrap density default documentation

---------

Co-authored-by: AdityaK-iiita <adityakolate0070@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): stop host resource fallback after unload (#325)

* fix(plugins): stop host resource fallback after unload

* fix(plugins): preserve resource warning for a missing result

---------

Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* Handle closed browser frames during navigation callbacks (#400)

* Handle closed browser frames during navigation callbacks

* test(browser): retain closed-event guard after navigation integration

* fix(browser): preserve helpers on PID failure and recognize closed transports

* style(browser): wrap review diagnostics for detekt

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* refactor(window): pin the panel content alignment width (#398)

* fix(window): restore strict width constraints for nested panels

Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation.

* Cover panel content alignment across width changes

* Keep panel layout regression in the existing app test package

* Document panel alignment contract and pin filling content width

* Wrap the panel width assertion to satisfy detekt

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* #320 - Fix quick actions vanishing in short tab rail (#328)

* #320 - Fix quick actions vanishing in short tab rail

* Fix: detekt & ktlint checks

* Fix rail action budgets and reversible fallback wiring

* Fix resize test assertion import and update rail coverage note

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(crash): refine scroll boundary and bound error sanitization (#350)

* fix(crash): refine scroll boundary and bound error sanitization

* Avoid exposing a partial token at the crash message limit

* Document sanitizer input bound and verify expansion ordering

---------

Co-authored-by: Aishwary Anand <aishwary.cd22@bmsce.ac.in>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(logging): redact private hostnames (#346)

* fix(logging): redact private hostnames

* Handle private hostname punctuation and preserve diagnostic ports

* Format hostname punctuation regression assertion

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat: Opening an .html file should ask open as a file or webpage (#333)

* feat: Opening an .html file should ask open as a file or webpage

* Fix HTML prompt delivery and persisted preference ordering

* Cover HTML routing and correct review test fixtures

* Honor HTML navigation intent and release pending prompts on failure

* Isolate HTML prompt handling from the general dialog host

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugin-store): parse timestamps consistently in list and detail metadata (#379)

* Fix: Implement proper ISO timestamp parsing in PluginStoreClient

* fix(repository): implement robust ISO timestamp parsing for Supabase payloads

* style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught

* fix(plugin-store): implement plugin metadata timestamp parsing

PluginStoreClient.parseTimestamp was a stub that always returned 0L, so
every plugin fetched from the store showed "Last Updated"/"Published" as
the Unix epoch in the Toolbox UI regardless of what the server actually
sent (#337).

Parses the ISO-8601 timestamp (the shape the store sends, e.g.
2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant,
falling back to 0L for a blank, missing, or malformed string rather than
throwing - matching the field's own empty-string default for a response
that omits it. java.time rather than adding kotlinx-datetime as a new
dependency: this module already uses java.util.* directly in the same
commonMain source set (it has one real target, jvm("desktop")), so nothing
is gained by introducing a second time library for one function.

Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the
convention already established next to it in
PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset
timestamp, a blank string, and a malformed string.

* Fix timestamp normalization edge cases and verify both response mappings

* Use release publication dates and pin timezone-independent fallback

---------

Co-authored-by: Sanjay <sanjaysaini4423@gmail.com>
Co-authored-by: Antriksh1984 <antrikshsingh850@gmail.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat(remote-ui): authenticate and expose remote UI surfaces (#348)

* feat(remote-ui): authenticate remote UI process identity

* feat(remote-ui): place authenticated remote UI surfaces

* fix(remote-ui): close ownership and placement lifecycle races

* test(remote-ui): expose placement recovery cases to JUnit

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat(dashboard): add shared read-only What's New release feed (#381)

* Add What's New release feed to dashboard

* Serialize update settings persistence

* Remove unused update settings import

* fix(updater): retain realtime refreshes during shared release fetches

* style(updater): format shared release fetch regression tests

* fix(updater): coalesce release refresh bursts and preserve visible history

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(language): consolidate host and out-of-process language-id tables (#358)

* fix(language): consolidate host and out-of-process language-id tables

EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor)
each hand-maintained their own extension-to-language-id table because
boss-app-editor - a plain JVM module compiled to a GraalVM native image -
could not depend on anything in composeApp. They disagreed: EditorServiceImpl
named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and
EditorServiceImpl was missing more than forty ids the other table had
(fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75).

Moves the table into a new plugin-platform/plugin-language-types module -
dependency-free by design, so it carries nothing extra into boss-app-editor's
native-image build - and has both EditorLanguages and EditorServiceImpl read
from it. This is the first time anything under modules/ depends on
plugin-platform/; verified compiling and passing tests before committing to
the approach.

EditorLanguages keeps its existing public API unchanged (same values for
every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest
needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry
and "plaintext" fallback as local additions on top of the shared table,
rather than folding them into it: proto isn't part of the boss-file-types.json
default-app-association surface the shared table backs, and "plaintext" is
this service's own gRPC default, not a value composeApp reads.

Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously
no tests existed for this class at all) - the latter pins the actual bug fix,
that detectLanguage("sh") now returns "bash" instead of "shell".

Out of scope, and disclosed rather than silently dropped: two of the five
duplicate tables the issue names - the editor-tab plugin's own
LanguageDetection and BossEditor's lexer registry - live in separate
repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in
plugin-icons is also not consolidated: it keys icon selection off the raw
filename to preserve distinctions a language id can't carry (package.json,
yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different
brand icons) and never computes a language id in the first place, so
routing it through this table would need a separate icon-selection redesign.

* fix(language): keep file-association drift checks on the shared table

* fix(language): apply shared filename rules to editor file opens

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(editor): contain write-local stack overflow and preserve fatal errors (#396)

* Make writeFileContentSafe actually safe: catch the Errors it was named for

`writeFileContentSafe` exists to return false rather than throw. It did not hold
that contract:

    } catch (e: Exception) {
        fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e)
        false
    }

StackOverflowError is an Error, not an Exception, so it went straight through -
out of writeFileContent, out of the editor_write_file MCP handler, and to the
caller as a bare StackOverflowError instead of the handler's own
"Write failed for <path>". The warn that would have named the file never ran
either.

That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two
independent reports of it, on unrelated content, and #27's complaint is precisely
the missing diagnosis: the error "gives no indication of which input caused it".

StackOverflowError and OutOfMemoryError are caught explicitly rather than
widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and
readFileContentSafe in this same file already treats OutOfMemoryError as a
reportable outcome. Throwable would also absorb LinkageError and ThreadDeath,
which are not this function's to swallow - there is a test asserting a
NoClassDefFoundError still propagates.

The failure log now names the path and the character count. Not the content:
these writes carry whatever the user is editing.

**This does not fix the write.** Whatever recurses is upstream of this function,
which is mkdirs plus writeText - I ruled out the plugin's handler, this write,
and the host's MCP argument parsing, and said so on the issue. What changes is
that the failure is reported honestly instead of escaping, which is where a
diagnosis can start.

The body moves to `guardedWrite` with an injectable write, because a test JVM
cannot be made to overflow the stack inside writeText on demand.

Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt.

Refs risa-labs-inc/boss-plugin-editor-tab#18, #27.

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

* docs(editor): clarify write guard scope and prior recursion fix

* fix(editor): preserve fatal heap errors in write guard

---------

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

* fix(state): apply version-checked JSON merge patches (#361)

* Fix: Implement proper ISO timestamp parsing in PluginStoreClient

* Feature: Implement JSON Merge Patch for plugin delta state synchronization

* fix(state): validate delta bases and exclude duplicate timestamp scope

* fix(state): reject malformed and deeply nested patches

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363)

Bumps `grpc` from 1.83.1 to 1.84.0.

Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0
- [Release notes](https://github.com/grpc/grpc-java/releases)
- [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0)

---
updated-dependencies:
- dependency-name: io.grpc:grpc-netty
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-protobuf
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-stub
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-services
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.grpc:grpc-util
  dependency-version: 1.84.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364)

Bumps `protobuf` from 4.36.0 to 4.36.1.

Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1

Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Commits](https://github.com/protocolbuffers/protobuf/commits)

---
updated-dependencies:
- dependency-name: com.google.protobuf:protobuf-kotlin
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.google.protobuf:protobuf-java
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): defer stale jar cleanup until restart (#340)

* fix(plugins): defer stale jar cleanup until restart

* fix(plugins): satisfy quality checks

* Preserve staged plugin artifacts when another plugin updates

* Require cleanup intent and retain artifacts with unordered manifest versions

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): defer fluck-browser hot-reload to a restart (#352)

* fix(plugins): defer stale jar cleanup until restart

* fix(plugins): defer fluck-browser hot-reload to a restart

Hot-reloading fluck-browser force-unloads the classloader that created its
JxBrowser native views. Every open tab - and every tab opened afterward,
since the factory that would recreate the view is gone too - draws an empty
box, and a second hot-reload does not recover it; only a full restart does.

HotReloadPolicy names plugins that must never be swapped in place, matching
the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at
every path that can force-unload a live plugin to apply an update: the
resetPluginInstances/doReloadPlugin path that actually reproduced this bug,
the menu-driven Reload action, the Toolbox update flow, and the store
installer. For a not-hot-reloadable plugin, the new jar is staged into
installed.json without touching the running instance or its open tabs, and
a status message tells the user to restart to apply it - the next cold
start loads the update with a fresh classloader.

Also fixes two call sites that asked "restart dependent plugins?" before
checking whether an unload was even going to happen, which produced a
confusing prompt for an unload that a deferred update was never going to
do.

* fix(plugins): satisfy quality checks

* Preserve staged plugin artifacts when another plugin updates

* Validate deferred browser artifacts and reject ineffective downgrades

* Require cleanup intent and retain artifacts with unordered manifest versions

* Preserve unloaded-browser recovery and unify safe deferred selection

* Format scoped update cleanup integration

---------

Co-authored-by: ayush <workside@gamil.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>

* Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365)

* Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135)

reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest.

* Serialize plugin spawn registration with reaping and preserve replacement handles

* Fence late process creation without blocking shutdown and cover recovery

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): complete loader cleanup after disposal linkage errors (#302)

* fix: API hot swap during startup

* fix: workflows

* …
@supabase

supabase Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Updates to Preview Branch (dev) ↗︎

Deployments Status Updated
Database ✅ Tue, 15 Sep 2026 04:56:09 UTC
Services ✅ Tue, 15 Sep 2026 04:56:09 UTC
APIs ✅ Tue, 15 Sep 2026 04:56:09 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations ✅ Tue, 15 Sep 2026 04:56:10 UTC
Migrations ✅ Tue, 15 Sep 2026 04:56:10 UTC
Seeding ✅ Tue, 15 Sep 2026 04:56:10 UTC
Edge Functions ✅ Tue, 15 Sep 2026 04:56:15 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@kshivang

Copy link
Copy Markdown
Contributor Author

@claude Please review this dev-to-main integration PR for correctness, security and integration regressions: trackpad release/cancellation, run/project/split bridge identity checks, fail-soft secret listing migration, clone timeout/cancellation, proactive MCP policy/activity history, and Spotlight project/session isolation. Check interactions with latest main including browser/editor exact-frame routing. Identify concrete blockers and distinguish existing issues. Fresh integration testing is underway; do not assume earlier individual PR checks validate the combined head.

@claude

claude Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 9m 54s —— View job


Integration review: dev → main (#695)

Scope note: this is a read-of-the-diff review. I was not able to run gradle in this environment (the ./gradlew invocation was refused), so there is no build, detekt, ktlintCheck, :composeApp:desktopTest, node scripts/test/test-swipe-nav.js or pgTAP evidence from me. Per your instruction I have not assumed the individual PRs' green checks carry to this head.


Blockers to resolve or explicitly accept before merge

1. The three newly-guarded kernel services have no in-repo proof the production client attaches a token

RunConfigurationService, ProjectDataService and SplitViewService now fail closed on a missing boss-process-token. The only production path that attaches it is ChildProcessBootstrap reading BOSS_PROCESS_TOKEN (modules/boss-ipc/.../ChildProcessBootstrap.kt:62-85) — and ProcessTokenClientInterceptor appears only in tests everywhere else in this tree. The consuming proxies take a bare ManagedChannel:

  • plugin-platform/plugin-api-ipc/src/desktopMain/.../RunConfigDataProviderProxy.kt:23
  • .../ProjectDataProviderProxy.kt:21, .../SplitViewOperationsProxy.kt:18

and are constructed by the out-of-tree boss-microkernel-runtime. This is exactly the "silent total outage rather than the pre-BossConsole#53 status quo" the BossIpcServer KDoc you edited in this PR warns about, and nothing in this repo's CI crosses that seam. Unlike Secret/RoleManagement (already fail-closed on main), these three go from working unauthenticated to total PERMISSION_DENIED for any runtime that builds its channel without the interceptor.

Compounding it: RunConfigDataProviderProxy.execute collapses the refusal into catch (_: Exception) → "Run could not be started." with no log at all, so a mis-wired runtime presents as a mute, undiagnosable Run button. At minimum log the StatusException code at that catch.

Host side is fine — KernelBootstrap.kt:486 builds the kernel BossIpcServer(kernelAddress!!, tokenRegistry) with a real registry.

2. execute now silently runs something other than what the caller sent

RunConfigServiceBridge.execute resolves the id against provider.detectedConfigurations and discards command, arguments, workingDirectory and environmentVariables. The hardening is right — this was an unauthenticated arbitrary-command primitive. Two loose ends:

  • The proxy still serialises and sends all those fields and gets Empty back, so a plugin UI that lets an operator tweak args before Run will run the un-tweaked provider copy with no signal. Either document that on RunConfigurationDataProvider.execute or return something distinguishable.
  • request.windowId is still caller-controlled and unvalidated (RunConfigServiceBridge.kt:186, and scanProject likewise). An authenticated child can still direct a terminal into any window and make the host scan any path. Worth stating as accepted scope in the KDoc, since the KDoc currently reads as if the whole request is now distrusted.

3. Fail-soft secret listing makes "corrupt" indistinguishable from "empty" — and the update RPC will destroy the ciphertext

COALESCE(public.try_decrypt_text(s.password_encrypted), '') in all three listing RPCs (20260914010000_...sql:72,122,233) returns '' for an undecryptable row, and SecretDecodingTest pins that the client happily accepts it. But update_secret(p_secret_id, p_website, p_username, p_password, …) (20251023000004_secret_functions.sql:140) takes the full plaintext password, non-optional, and re-encrypts it. So an operator who loads the list, edits only the website on a corrupt row and saves, overwrites a still-recoverable ciphertext with encrypt_text('') — permanently.

Nothing in the migration or in SecretEntry/SecretEntryWithSharing carries a "this failed to decrypt" flag, so the client cannot refuse. Before shipping, either:

  • confirm the secret-manager plugin's save path never sends a password it did not successfully load, or
  • add a sibling boolean (e.g. password_decrypt_failed) to the three RETURNS TABLE signatures — note the client's supabaseJson already tolerates unknown columns, so this is additive and safe for installed builds.

The lockdown itself is correct: try_decrypt_text is REVOKEd from PUBLIC/anon/authenticated/service_role and granted only to postgres, after the CREATE, and both identity_disclosure_audit.sql and explicit_anon_and_org_visibility_test.sql gained its signature. Rotation keeping strict decrypt_text is the right call.


Notable, non-blocking

4. Per-wheel-event synchronous renderer→JVM IPC (#650). swipe-nav.js:onWheel now calls bridge.activeGestureId() on every pixel-mode wheel event, and ScrollPhaseObserver.claimCurrent takes lock — the same monitor the CoreGraphics tap thread holds across two System.setProperty calls plus N listener dispatches in deliver(). This directly inverts the rule BrowserSwipeNavScript.stateUpdate's own KDoc states ("A synchronous call from the page's JS thread into a bridge method … is a deadlock waiting for a slow renderer"). It is mitigated — switchedOff() and deltaMode pre-filter, listeners are enqueue-only, BrowserHandleImpl.onSwipeGestureEnded honours that — but the enqueue-only invariant is enforced only by comment, and the feature defaults on. docs/review/pr650-followup.md's physical checklist is all gesture cases; please add an explicit "scroll a heavy page hard and watch for jank/stutter" item to it.

5. reset() moved to the top of onWheel. Once puck exists in a document, turning the gesture off makes every subsequent wheel event write three inline styles (hideAffordance, swipe-nav.js:288). Harmless before the affordance is ever drawn (early !puck return), cheap after — but it is per-wheel-event DOM work for a feature the user switched off. A if (direction !== 0) guard would remove it.

6. ScrollPhaseObserver.setEnabled FAILED→retry starts a second observation thread while the first may still be inside CFRunLoopRunInMode(mode, 0.25, …) holding a live tap (MacOSScrollGesturePhases.kt:226-241, :437). Two listen-only session taps coexist for ≤250 ms. Benign, but it is the "turn swipe navigation off and on to retry" path the Settings copy now instructs users to take, so worth a look during the manual pass.

7. SpotlightFileIndexOwner.indexerFor is a side effect in a composable body. BossAppDialogs.kt:112 calls it unconditionally; on a path change it cancels a job, clears an index and constructs a new FileIndexer. Compose is permitted to run and abandon a composition. Move the cancel/clear into a remember(selectedProject.path) or DisposableEffect and keep indexerFor a pure lookup. Fix this →

8. FileIndexer.indexProject changed from tryLock-skip to withLock-queue, and the bound that makes that safe now lives outside the class: the KDoc says "callers that share an indexer must avoid submitting unbounded work" and only SpotlightFileIndexOwner.ensureIndexed's indexingJob?.isActive guard supplies it. FileIndexer is a public class with a public suspend entry point.

9. SwipeNavParityTest hard-requires node on PATH and throws IOException rather than skipping — a new host-tooling coupling on ./gradlew build across the three-platform matrix, and the opposite of this repo's own convention (RecoveredSpacesRoundTripTest skips on a missing fixture). root also resolves via generateSequence(File(user.dir))…first{}, which throws NoSuchElementException rather than skipping if the walk fails. The follow-up doc says the hard error is deliberate; flagging so it's a decision rather than a surprise on the first red Windows run.

10. McpActivityLogDialog renders up to 100 records as a plain Column in a verticalScroll (:144-152), ~5 composables per row all measured on open. A LazyColumn would be the obvious shape. Also: the dialog renders errorSnippet verbatim, and AGENTS.md's own caveat ("redaction is bounded and best effort, not a guarantee for secrets under arbitrary keys") now applies to something on screen rather than in a file — acceptable for operator-only host UI, but it's the reason #416's plugin move must stay permission-gated, which your AGENTS.md update already says.

11. BrowserSwipeNavScript.release interpolates end.accumX.toString() raw into an executed statement. Safe today (every JVM Double renders as NaN/±Infinity/decimal/scientific, all valid JS expressions), but that's a non-obvious invariant holding up a code-generation boundary — worth one line of comment beside the "Only typed native values cross this boundary" note.


Verified correct (spot-checked, no action)

  • Exact-frame routing interaction: injectPageHelpers is still reached only with the navigation main frame (BrowserHandleImpl.kt:1350-1367), and onSwipeGestureEnded executes the release on browser.mainFrame() — the two agree, so there is no inject-here/release-there split. Main's exact-frame work is the context-menu menuContextAuthority path, untouched; the only new coupling is swipeGestureClaim.close() added beside menuContextAuthority.invalidate() in both BrowserClosed and dispose(), and close() → listeners.remove(id) is idempotent, so the double call is safe. No conflict found.
  • Clone cancellation (fix(git): make clone timeout cancellable #660): catch (TimeoutCancellationException) correctly precedes catch (CancellationException); the inner withTimeout is scoped so a timeout does not cancel the caller; the pre-existing targetDir.exists() refusal means onCancellation's deleteRecursively can only remove what git itself created; cleanupCloneProcess kills the process tree before invoking cancellationCleanup; and a drain failure after a successful exit leaves processExitObserved = true, so a completed checkout is never deleted.
  • setToolPolicyIfAbsent (feat(mcp): let operators set tool policy proactively #636): the toolName !in _config.value.rules recheck is inside the same synchronized(lock) as the write, and the provider-DENY check is ordered after it — correct, since a tool-level DENY is already a rule. The reasoning in the KDoc about revocationVersion not moving on an explicit ASK/ALLOW is right, and expectedRevocation being kept as a second guard rather than the only one is the right call.
  • unsuccessfulCategory as an exhaustive when with no else — this is the right shape, and PROVIDER_TRUST_PERSIST_FAILED being excluded from WITHHELD matches its own KDoc.
  • Spotlight (Fix/spotlight project session isolation #593): no dangling callers of the removed GlobalSearchService singleton state anywhere in the tree; indexedFiles as a LaunchedEffect key short-circuits on reference equality so it is not a deep list compare per recomposition; dialogState = remember(projectPath) plus the conditional caller means a reopen genuinely cannot restore a prior session's query/selection.
  • try_decrypt_text exception handling: class 22 / class 39 is the right pair; the key-configuration check sits outside the inner handler so a systemic misconfiguration still raises rather than silently blanking every row; SET search_path TO '' with fully-qualified calls throughout.

Suggested additions to the pre-merge manual list

  1. KERNEL mode end-to-end with the real boss-microkernel-runtime: run-config scan + execute, project switch, and a split-view openFileInEditor — all three now refuse without a token, and none is covered by this repo's CI.
  2. A corrupt-password row: load the secret list, edit an unrelated field on the affected row, save, then check password_encrypted is unchanged.
  3. Heavy-page scrolling on macOS with swipe nav on (item 4 above), in addition to the gesture checklist already in docs/review/pr650-followup.md.
    · branch dev

@kshivang

Copy link
Copy Markdown
Contributor Author

Integration validation on 236937c:

  • 350 focused Kotlin tests passed: 338 host desktop tests and 12 RunConfig proxy tests; zero failures/errors/skips. Coverage includes MCP governance/activity/policy UI, Spotlight ownership/isolation/search, clone lifecycle/process trees, run/project/split identity, and swipe/native phase handling.
  • composeApp ktlintCheck and detekt passed. Working tree clean.
  • Swipe JavaScript regression suite passed.
  • Fresh disposable PostgreSQL migration/bootstrap and all 860 pgTAP assertions across 35 suites passed. Only test project boss-pr695-check was created and removed; no production database changes.
  • Live debug BOSS MCP on port 7677: responsive metrics; disposable browser tab opened, listed, focused, navigated with URL/DOM readiness verified, and closed with return to original empty-tab state.

Limits: automated Compose tests and synthetic/native-phase tests do not establish physical trackpad behavior, native dialog appearance or real out-of-process KERNEL-runtime interoperability. Manual trackpad hold/release/reversal, multi-window Spotlight isolation, MCP activity/policy visual interactions, and clone-dialog experience remain to be checked. No live secret data was inspected. Claude review is currently in progress; one macOS CI run is still in progress, other reported build/quality/database checks are green.

@kshivang

Copy link
Copy Markdown
Contributor Author

Follow-up from manual debug testing: horizontal spreadsheet gestures could still navigate browser history. Fixed in d9c0cbb. Canvas/ARIA grid/treegrid surfaces retain gesture ownership without requiring a native scroll range; explicit overscroll containment is respected even with no range. Retaining the latest wheel event also catches preventDefault from window handlers that run after the observer, both on subsequent events and final native release.

Six new regression cases failed before the fix and pass after it. Existing swipe JavaScript suite, 42 focused host swipe/native-phase tests, ktlintCheck and detekt pass. Debug build restarted with this fix and companion browser PR #45 JAR retained. The exact reported spreadsheet site still needs physical retesting; this does not claim every custom widget has been visually verified.

@kshivang

Copy link
Copy Markdown
Contributor Author

@claude Please review the latest head 2d4e577 of this dev-to-main PR, including the follow-up MCP policy UI and bulk policy engine changes. Focus on atomic section/global presets (All/View/Edit/None/Custom), concurrent policy/revocation changes, provider denials, failure handling, correct read-only classification, plugin display names/search, and UI state/confirmation behavior. Also revisit any still-relevant findings from your earlier integration review against the current code. Please post concrete findings with locations and distinguish blockers from suggestions. We will evaluate and fix valid findings before merging. Latest focused UI/bulk-policy tests and quality checks pass; earlier integration validation is documented above.

@claude

claude Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 6m 55s —— View job


Integration review: dev → main (#695), head 2d4e577

  • Read the six new commits since my last review (8999511…2d4e577, plus d9c0cbb/e1d495f)
  • McpPolicyEngine.setSectionPolicies: atomicity, revocation, provider DENY, failure handling
  • Section/global presets and read-only classification
  • Plugin display names + search
  • UI state / confirmation behaviour
  • Revisit still-relevant earlier findings
  • Post findings

Scope note: read-of-the-diff again. ./gradlew is still refused in this environment, so there is no build, detekt, ktlintCheck, :composeApp:desktopTest, node scripts/test/test-swipe-nav.js or pgTAP evidence from me. I have not assumed your green runs cover what I am pointing at below.

The engine half of the bulk-policy work is solid — setSectionPolicies is the shape I'd want, and McpSectionPolicyTest pins the five cases that matter. The findings below are almost all in the UI layer above it.


Blockers

B1. The View preset classifies tools with only two of the three signals policyFor uses, so it can durably ALLOW a tool the engine itself treats as mutating

composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpPolicySections.kt:45

internal fun McpToolIdentity.isViewTool(): Boolean = readOnly && !McpMutatingToolCatalog.isMutating(toolName)

McpPolicyEngine.policyFor (McpPolicyEngine.kt:186-191) decides the same question with three:

val risk = DefaultMcpRiskEvaluator().evaluateRisk(toolName, McpToolArgs(emptyMap())).level
return if (risk >= McpRiskLevel.HIGH || McpMutatingToolCatalog.isMutating(toolName)) { defaultMutatingAction } else { … }

The risk >= HIGH clause is missing from isViewTool, and it is not redundant with the catalog. Concretely, secrets_list and secret_search are in DefaultMcpRiskEvaluator.SECRET_MANAGEMENT_TOOLS → HIGH (mcp/sandbox/McpRiskEvaluator.kt:137-145), are not in McpMutatingToolCatalog.KNOWN_MUTATING_TOOLS, and match no entry in MUTATING_SUFFIXES (mcp/McpPolicy.kt:84-129). readOnly is plugin-declared metadata (BossBottomBar.kt:454 → it.definition.readOnly, from the api jar), and a list/search tool declaring itself read-only is the expected case.

So: one tap on View → Confirm all sections writes a persisted ALLOW for the credential-vault listing and search tools, for every agent and every argument set, across restarts and replacement plugins — while the engine's own default for those same names is defaultMutatingAction (ASK in the shipped config). The path that exists to make governance visible is the one that quietly widens it.

McpProactivePolicyDialogTest's view section preset stages all rules and excludes known mutations pins the catalog correction (k8s_delete declared readOnly = true) — which is exactly why the missing risk correction reads as covered.

One line, and it puts the two classifiers back in agreement:

internal fun McpToolIdentity.isViewTool(): Boolean =
    readOnly &&
        !McpMutatingToolCatalog.isMutating(toolName) &&
        DefaultMcpRiskEvaluator().evaluateRisk(toolName, McpToolArgs(emptyMap())).level < McpRiskLevel.HIGH

(Worth pinning as its own test with secrets_list, not k8s_delete — the catalog already catches that one. Note also that the risk evaluator strips an mcp__boss__ prefix and isMutating does not, so the two disagree on a prefixed name; policyFor survives that because risk covers it, and isViewTool currently would not.) Fix this →

B2. The proactive single-tool path — with its risk display and its confirming second tap — is now unreachable, and nothing in the section UI replaces either guard

McpPolicyManagerDialog.kt:558:

if (sections != null && onApply != null) { McpPolicySections(...) } else { FilteredPolicyCandidates(...) }

The only production call site (BossBottomBar.kt:405-414) always passes both. So FilteredPolicyCandidates, ProactivePolicySectionContent, ProactivePolicyRow, ProactiveAllowConfirmation, PolicyChoice, saveProactiveToolPolicy/onSetPolicy, onRefreshCandidates/candidateRefresh and the whole availableTools/filteredTools chain are dead outside tests — the sectionTools = null branch survives only in McpProactivePolicyDialogTest.

That matters because of what it took with it. AGENTS.md, updated in this same PR, states the contract:

Allow requires a second confirming tap and shows the tool's risk assessment first, the same way the approval dialog's own "Always Allow" does, since it is the same durable, tool-name-wide grant.

SectionConfirmation (McpPolicySections.kt:241-282) shows counts and a sentence, then one Button. No DefaultMcpRiskEvaluator output, no "performs mutations or external execution" line, no second tap — and it grants ALLOW to many tools at once rather than one. The guard was weakened exactly where the grant got wider, and the code that implements it is still in the tree looking like it applies.

The asymmetry inside the one dialog is the clearest way to see it: removing a single DENY from the saved-rules list below needs two taps (McpPolicyManagerDialog.kt:271-278, "Remove denial" → "Confirm remove?") because it raises privilege — while a section preset replaces that same DENY with ALLOW in one tap, which raises it strictly further. McpSectionPolicyTest's section saves every rule and invalidates previous queued grants pins that DENY→ALLOW overwrite as intended behaviour, so this is a UI-guard gap rather than an engine one.

Either restore a confirming second tap + risk context when a preset's staged set contains any HIGH/CRITICAL or currently-DENYed tool, or delete the now-unreachable proactive branch and amend that AGENTS.md paragraph so it describes what actually ships. Leaving both as they are means the documented guard, the dead code and the live path tell three different stories. Fix this →


Should fix before merge

3. A Failed apply says "try again" and removes the button to try again

McpPolicySections.kt:269-278:

val outcome = onApply(changes)
feedback = outcome.proactivePolicyMessage() ?: "Policies saved."
onDone()
onRefresh()

onDone() fires for every outcome. For the section control that is dirty = false (:148), which removes the whole if (dirty) block — summary text and Confirm button — while selected/mode keep the staged values, because remember(tools, rules) only resets when one of those keys actually changed.

For Refused that is survivable: a genuine refusal means either revocationVersion moved (so sectionTools changes) or rules[tool] changed (so rules changes), and either re-keys the remember. For Failed, PolicyUnreadable and Denied nothing changed, so the section is left displaying an unsaved staged selection as though it were current — "N selected", ticked checkboxes, the preset radio — alongside "Could not save this rule. Check the host log and storage, then try again." and no affordance to try again. The operator has to re-toggle something to get the button back, and until they do the section is actively misreporting saved state.

Gate it: if (outcome == McpProactivePolicyOutcome.Saved) { onDone() }, and call onRefresh() only for Refused (the outcome whose message promises refreshed candidates) — matching what ProactivePolicySectionContent:404 already does on the per-tool path. Fix this →

4. The section rows cannot show what a preset is about to replace: no rule, ASK and DENY all render as one unticked checkbox

SectionToolRow (McpPolicySections.kt:193-223) renders the tool name, the View/Update classification and the description — and nothing about its current saved rule. selected is initialised from rules[it.toolName] == McpPolicyAction.ALLOW (:105), so three genuinely different states collapse into "unticked", and confirming writes DENY to all three (:264).

Two consequences an operator cannot see coming from this screen:

  • A tool that currently prompts (explicit ASK, or no rule under an ASK default) becomes a persisted DENY — it stops asking and starts failing. View and Edit each do this to the entire complement of their own selection, across every section, including sections hidden by search.
  • There is no preset that means "ask for the rest". None (McpGlobalPolicyControls.kt:49) denies everything; recovering prompting means removing each denial individually, two taps each, from the saved-rules list.

AGENTS.md says "existing tool rules are replaced only after the operator confirms the displayed counts and scope", but the displayed counts (:243) describe the resulting state, not what is being overwritten. Showing each row's current rule, and a "replaces N existing rules (M denials, K asks)" line in the summary, would make that sentence true. Whether an Ask preset belongs here is your call — but the ASK → DENY conversion should at least be visible.

5. Per-wheel-event composedPath() walk added to every page, for a docs.google.com adapter

swipe-nav.js:569-572 (from d9c0cbb/e1d495f):

w.addEventListener('wheel', function (event) {
    capturedWheel = event;
    capturedSheetBoundary = switchedOff() || event.deltaMode !== 0 ? null : sheetsBoundary(eventPath(event));
}, { capture: true, passive: true });

sheetsBoundary bails on its first line unless the host is docs.google.com and the path starts /spreadsheets/ — but eventPath(event) is an argument, so the full composedPath() walk and array build happens on every pixel-mode wheel event on every page, before that check can decline. This lands on top of the synchronous bridge.activeGestureId() renderer→JVM hop I flagged as item 4 last time, on the same hot path, in a feature that defaults on. Hoist the host/path test (or a remembered boolean) above the call and pass the event, computing the path only on Sheets.

Minor, same hunk: capturedWheel and lastWheelEvent each retain a WheelEvent — and therefore its target — until the next wheel event, which can hold a detached subtree alive after a SPA teardown. reset() (:355-360) clears lastWheelEvent but not capturedWheel.

6. chainCanScroll's new ownership rules are much broader than "spreadsheet"

swipe-nav.js:173-199. Two new unconditional return trues (page owns the gesture, so never navigate):

  • el.tagName === 'CANVAS' || role === 'grid' || role === 'treegrid', for any element in the scroll path, with no test that it is wide or scrollable. A decorative background <canvas>, a charting library's canvas, or role="grid" on a non-scrolling table or a date picker is enough to disable swipe-back on that page. role="grid" in particular is what several table and calendar components emit by default.
  • overscrollX === 'contain' || 'none', now checked before the range <= 1 skip. overscroll-behavior: contain on html/body is a very common pull-to-refresh/scroll-chaining defence, and the root gets a special case a few lines down — so a site setting it site-wide now disables swipe navigation everywhere on that site.

Your own commit message scopes the claim correctly ("does not claim every custom widget has been visually verified"), and the failure direction is the safe one (no navigation rather than a wrong navigation). Flagging it so the breadth is a decision: the physical checklist in docs/review/pr650-followup.md is all gesture cases, and nothing in it would catch "swipe-back stopped working on an ordinary article page that happens to contain a chart".

Also worth writing into that doc: .grid-container / .native-scrollbar-x (swipe-nav.js:145-150) are unversioned Google internals. When they change, sheetsBoundary returns null and the canvas rule takes over — so it degrades to "page owns the gesture", which is right, but silently.


Suggestions

  1. McpSectionMode.Custom is doing two jobs. In the section radios it means "individual checkboxes"; in the global presets it is what None maps to (McpGlobalPolicyControls.kt:49), relying on sectionSelection(tools, Custom) returning the empty set (McpPolicySections.kt:57). savedSectionMode then returns Custom for both "no rules at all" and "explicitly deny everything" (McpSectionMode.kt:13). A distinct None member would make each site say what it means and make savedSectionMode able to report a fully-denied section as denied.

  2. The same preset is labelled Edit globally and Update per-section. McpGlobalPolicyControls.kt:48 hardcodes "Edit" to McpSectionMode.Update, while SectionModes renders mode.name (McpPolicySections.kt:184) → "Update", and the per-tool line says "Update · changes state…" (:211). Two words for one concept, both on screen at once.

  3. sectionTools is recomputed on every recomposition while availableTools beside it is remembered (BossBottomBar.kt:389-404 vs :405-411). Harmless today only because McpToolIdentity is a data class, so the fresh list compares equal and the remember(tools, rules) keys inside McpPolicySection survive — i.e. correctness rests on a structural-equality coincidence rather than on the remember the sibling call already has.

  4. SectionConfirmation's feedback is remembered unkeyed (:240), so "Policies saved." stays on screen while the operator stages a different selection. Clearing it in the onClick before saving = true, or keying it on the staged set, would stop a stale confirmation sitting under a pending change.

  5. Search hits the two halves of the dialog differently. The placeholder says "Find a tool or provider", and sections match on display name, provider id, tool name and description (McpPolicySections.kt:72-78) — but the saved-rules list filters tool names only (McpPolicyManagerDialog.kt:115, rules.filterKeys). Searching a plugin's display name shows its section and then "No matching saved rules" underneath, for a plugin that has some.

  6. mcpPolicyPluginNames puts a composable call in an elvis branch: LocalPluginStates.current?.collectAsState() ?: remember { mutableStateOf(emptyMap()) } (McpPolicyPluginNames.kt:12). It works, but a collectAsState() and a remember in two arms of a ?: at one call site is the fragile form of this; val flow = LocalPluginStates.current then an explicit if is the same thing without the reliance on how the compiler groups the branch.

  7. setSectionPolicies's fault names a provider id in a tool-shaped message. key = changes.first().providerId (McpPolicyEngine.kt:369) is handed to McpPolicyFault.PolicyPersistFailed(toolName, …), whose message reads "Could not save MCP policy for ''" — and for a global preset that provider is whichever section happened to sort first, which is not the failure's scope at all. A SectionPolicyPersistFailed(count, …) variant, or just a "<n> tools" key, would stop the fault line naming an arbitrary plugin.

  8. savedSectionMode allocates a setOf(ALLOW, DENY) per tool (McpSectionMode.kt:11). Trivial, but it runs per section per recomposition.


Earlier findings, re-checked against 2d4e577

Unchanged and still open — no commit since my last review touched them:

# Finding Location
1 Three newly fail-closed kernel bridges have no in-repo proof the production client attaches boss-process-token; ProcessTokenClientInterceptor is still test-only outside ChildProcessBootstrap RunConfigDataProviderProxy.kt:23, ProjectDataProviderProxy.kt:21, SplitViewOperationsProxy.kt:18
1b RunConfigDataProviderProxy.execute still collapses the refusal into catch (_: Exception) → "Run could not be started." with no log (:147-151), so a mis-wired runtime is a mute Run button RunConfigDataProviderProxy.kt:145-151
2 execute resolves the id and discards command/arguments/workingDirectory/environmentVariables while the proxy still sends them and gets Empty back; request.windowId remains caller-controlled RunConfigServiceBridge.kt:186
3 COALESCE(try_decrypt_text(...), '') makes corrupt indistinguishable from empty, and update_secret takes a non-optional plaintext password — editing an unrelated field on a corrupt row overwrites recoverable ciphertext with encrypt_text('') 20260914010000_…sql:72,122,233
4 Synchronous per-wheel bridge.activeGestureId() into ScrollPhaseObserver.claimCurrent's lock — now compounded by #5 above swipe-nav.js:onWheel
6 ScrollPhaseObserver.setEnabled FAILED→retry can start a second observation thread while the first is inside CFRunLoopRunInMode(mode, 0.25, …) MacOSScrollGesturePhases.kt:226-241, :437
7 SpotlightFileIndexOwner.indexerFor is still a side effect in a composable body — it cancels the job and clears the index BossAppDialogs.kt:112
8 FileIndexer.indexProject's withLock-queue bound lives outside the class, supplied only by ensureIndexed's isActive guard FileIndexer.kt
9 SwipeNavParityTest hard-requires node and throws rather than skipping SwipeNavParityTest.kt
10 McpActivityLogDialog still renders up to 100 records as a plain Column in a verticalScroll (:85) rather than a LazyColumn McpActivityLogDialog.kt:85
11 BrowserSwipeNavScript.release interpolates end.accumX.toString() raw into an executed statement BrowserSwipeNavScript.kt

Item 5 from that review (reset() at the top of onWheel) is now moot — the hunk was restructured by e1d495f.

Verified correct in the new commits (no action)

  • setSectionPolicies is genuinely atomic and the guard order is right. The distinctBy, the per-change revocationVersion(tool, provider) != expected and rules[tool] != expectedRule pair, and the write all sit inside one synchronized(lock). The expectedRule half is the one that matters and is easy to omit — revocationVersion only moves on a revoke, so an intervening reactive ASK/ALLOW is caught by nothing else. McpSectionPolicyTest's intervening rule change refuses entire section and reset invalidates section even when rules return to the same value pin exactly those two.
  • Duplicate tool names cannot reach the distinctBy refusal. McpToolRegistryCore.recompute (:682-701) already dedupes allTools by name (first provider wins, logged), so the one path that would turn Confirm all sections into a permanent, operator-unfixable Refused is closed upstream.
  • Provider-DENY gating is scoped correctly, not copied from setToolPolicyIfAbsent. Refusing only when action == ALLOW (:359-365) is right: a DENY or ASK written under a provider-wide DENY cannot widen anything, since policyFor returns DENY from the provider rule regardless.
  • Revocation bump and session-trust clearing happen only after Saved (:378-381), and on Failed the previous _config stands — failed disk write leaves every rule unchanged pins it. The asymmetry with revokePersistedPolicy, which bumps even on failure, is correct in both directions: a failed reset must still invalidate queued answers, a failed grant has nothing to invalidate.
  • PolicyUnreadable is checked first and the damaged file is never overwritten (:346-348, pinned by unreadable policy is never overwritten by a section), consistent with the preserve-for-manual-recovery posture AGENTS.md states.
  • The global preset really does cover search-hidden sections. McpGlobalPolicyControls receives the unfiltered tools (McpPolicySections.kt:86) before groups is filtered (:72), and the copy says so (McpGlobalPolicyControls.kt:59) — the failure mode where a preset silently applies to only what is on screen is closed.
  • policySectionName degrades sensibly: display name when the provider id is a loaded plugin, otherwise the humanised last id segment — never a blank header (McpPolicyPluginNames.kt:20-25).
  • mcpProactivePolicyCandidates excluding disabledToolNames is right, and interacts correctly with persistedSetUnreadable (which folds every name into _disabled, McpToolRegistryImpl.kt:717-718): the section list empties and the global presets disable via tools.isNotEmpty() rather than writing rules while the disable set is unknown.
  • The activity status line's affordance: pointerHoverIcon(Hand) + onClickLabel + Role.Button + tooltip, and reachable with an empty ledger so "has anything used MCP yet?" is answerable before the first call.

Pre-merge manual list (adds to what's already there)

  1. Settings-free path: open Tool policies, tap View → Confirm all sections, then check ~/.boss/mcp-tool-policy.json for an ALLOW on any secret* tool (B1).
  2. Stage a section preset with the policy file made unwritable (chmod, or a directory in its place) and confirm there is still a button to retry after the failure message (finding 3).
  3. An ordinary page containing a <canvas> or a role="grid" table, and a site with overscroll-behavior: contain on body — swipe-back should still work on both, or the breadth in finding 6 needs narrowing.
  4. Heavy-page scrolling with swipe nav on, watching for jank (findings 4 + 5).
    · branch dev

@kshivang

Copy link
Copy Markdown
Contributor Author

Review follow-up pushed in 312eb1c.

Addressed the current review:

  • B1: View now checks the engine's HIGH/CRITICAL risk classification in addition to plugin readOnly and the mutating catalog. Regression coverage uses secrets_list and its prefixed name.
  • B2: the live section/global path now shows per-tool risk context and requires Review sensitive allows → Confirm sensitive allows for high-risk grants or replacing an existing DENY. Reviewing alone writes nothing. Changes to the tool/rule/selection snapshot disarm confirmation. Ordinary preset selection remains staged before saving; policy writes were never immediate on radio selection.
  • 3/10: Failed, Denied and PolicyUnreadable outcomes retain the staged choices and retry action. Saved and Refused refresh the snapshot; Saved refresh is deliberately retained because even a no-op durable write advances revocation stamps. Feedback is keyed to the reviewed selection rather than surviving a different edit.
  • 4/11: expanded tools show the current saved Allow/Ask/Deny/default state; the summary reports replaced rules and new default-to-Deny changes. Saved rules also match plugin display names/descriptions in search. None intentionally means Deny, as requested; no unrequested Ask preset was added.
  • 5: Sheets eligibility is checked before constructing the capture-phase event path. Capture references are consumed/reset, with microtask cleanup if a page stops propagation. A regression verifies ordinary pages construct one path per contact, not per capture event; existing Sheets edge/new-contact cases remain green.
  • 7–9/12–14: None is distinct from Custom, Edit is consistent at both levels, section candidates are remembered, plugin-state collection uses an explicit branch, bulk faults name the tool count rather than an arbitrary provider, and the saved-mode predicate no longer allocates a set per tool.

Earlier review follow-ups:

  • Added status-code-only Run RPC logging; command/argument/exception text is not logged. Documented host-id-based execution and the existing authenticated caller's window/path scope.
  • Moved Spotlight index switching/cancellation into a LaunchedEffect after composition commits; abandoned composition no longer mutates the owner.
  • The actual external runtime source does wire the token-bearing channel: boss-microkernel-runtime at 7ac0607ee7884b04a4b225bbdc3cf732c83cb30f constructs ChildProcessBootstrap, calls connect, and passes connection.kernelClient.channel to RemotePluginContext and the run/project/split proxies. The host ProcessSpawner supplies BOSS_PROCESS_TOKEN, and ChildProcessBootstrap installs ProcessTokenClientInterceptor. This is source verification, not a claim of full production KERNEL end-to-end testing.
  • The current secret-manager edit UI does not perform the reported blank-password overwrite: at adbb8d94c76eec51550a7d0ee98ef710a8e8b307, SecretManagerContent.kt:1533 and :1550 both require password.isNotBlank() before constructing/submitting UpdateSecretRequestData. An undecryptable blank row therefore requires an explicitly entered replacement password. This addresses the review's requested check of that client path; it does not claim all possible clients enforce that guard. No database change/deployment was made for this follow-up.

Retained intentionally: conservative page ownership over unknown canvas/grid surfaces and explicit overscroll containment, with the tested Sheets edge adapter as the exception. The physical checklist now documents ordinary canvas/contained pages, heavy-page jank, private Sheets-selector fallback and native-observer retry. The synchronous contact claim must remain renderer-independent; obsolete native generations are ignored. FileIndexer has one production indexProject caller, the bounded Spotlight owner. Node/source availability remains a required parity-test prerequisite, not a silent skip. The bounded activity list is a non-blocking virtualization improvement, not changed here. The legacy single-tool rendering helpers remain, but the new risk/confirmation tests exercise the live section/global path; candidateRefresh/onRefreshCandidates are still used by that path and are not dead.

Validation: 271 host tests + 12 RunConfig proxy tests passed (zero failures/errors/skips), including MCP governance, section failure/risk behavior, Spotlight, bridge identity and swipe/native-phase suites. ktlintCheck and detekt passed. JavaScript swipe regressions passed. A subsequent focused UI run passed and the sensitive-grant dialog capture was inspected. Fresh-head CI still needs to finish. Physical scroll performance and full KERNEL-runtime integration remain manual checks; no live secrets were inspected or modified.

@kshivang
kshivang merged commit 8f81c34 into main Sep 15, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants