Skip to content

fix(history): retain failed downloads and recent-page dismissals - #1477

Merged
kshivang merged 243 commits into
devfrom
review/recent-download-history-1421-1422
Sep 25, 2026
Merged

kshivang merged 243 commits into
devfrom
review/recent-download-history-1421-1422

Conversation

@kshivang

Copy link
Copy Markdown
Contributor

Consolidated review of #1421 and #1422

This ready-for-dev PR consolidates two legitimate, independent fixes from @manishakuhar, prepared oldest-first on current dev:

The changes do not overlap in source files, but were reviewed and validated together to avoid integrating either stale branch base. Author attribution is preserved on both commits via -x cherry-picks.

Validation

  • ./gradlew :composeApp:desktopTest --tests 'ai.rever.boss.components.plugin.panels.left_top.DownloadDataProviderImplTest' --tests 'ai.rever.boss.dashboard.RecentPagesDismissalDuringLoadTest' --no-daemon
  • ./gradlew :composeApp:ktlintCheck :composeApp:detekt --no-daemon

Both passed on current dev (9.5.22). Related merged #821 and open #1077 were checked; #1422 is follow-up work, not a duplicate. #1407 and #1195 were also checked for adjacent download scope.

shivanshu-risa and others added 30 commits September 21, 2026 21:42
…h list

The sectioned (All-category) view of the double-shift search never lit up
the selected row. SearchResultsList advanced a loop-mutated var per
section and the items(...) composable lambda read it by reference - the
lambda runs only when the LazyColumn composes the slot, after the walk
has finished, so the var held the list's total count and no row's
computed global index could ever equal selectedIndex. The flat
single-category view passes its index straight in and always highlighted,
which is why the bug survived: only the default view, the one double-shift
opens, was affected.

Compute each section's start up front in sectionStartsFor (a pure function
mirroring the drawing loop's walk, in the style of listItemIndexFor) and
capture that value by value into the row lambda. Selection math becomes
sectionStart + localIndex == selectedIndex, which is also what Enter
already indexes, so the highlighted row is now the row Enter opens.

Five tests pin the walk: first-occurrence starts, bijection of section
ranges over the result indices, absent categories, and the empty list.
Address the Claude diff review on the global-search highlight fix:

- sectionStartsFor now takes the drawing loop's own grouping instead of
  re-grouping the list, so there is one source of truth and getValue is
  safe by construction
- pin the getFilteredResults -> sectionStartsFor coupling with a test
  that feeds the real producer's output through the walk
- pin the walk's input contract (enum-order walk over counts) with an
  explicit interleaved-input test
- add a consistency test: section-derived row positions agree with
  listItemIndexFor for every row
- drop the overstated KDoc claim; state the two tests that pin the
  coupling
detekt caps code lines at 120 (ktlint allows 140), and ktlint wants the
helper as a one-line expression body - the annotated signature exceeded
120. Simplify instead of fighting both linters:

- the flat branch never reads groupedResults (it draws results directly),
  so its map was dead weight - emptyMap() and a non-null-key map type
- sectionStartsFor takes Map<SearchCategory, List<SearchResult>> again
- the test helper is a plain expression body with no type annotation
loadAsync assigned data.pages straight into _recentPages, and
bootstrapFromBrowserHistory did the same with its converted entries. init
launches that load and recordPageVisit can land while the read is in
flight - which is exactly why recordPageVisit uses update{} - so the page
the user had just visited was dropped from the list and from the save
scheduled for it. The bootstrap path is worse: it writes immediately, so
the assignment persisted over the racing visit.

This is the defect #795 fixed for the sibling RecentFilesManager, which
was modelled on this class for its lock discipline, debounce swap and
atomic write. Those three were already right here; this fourth one was
not.

mergeRecordedPages differs from RecentFilesManager's mergeRecorded because
visitCount accumulates: the racing entry is created fresh with a count of
1 because the list was empty, so taking the newer entry wholesale would
reset a long-lived page from its real count to 1. Counts are summed, the
same way UrlHistoryManager merges duplicates; the newer lastVisited
supplies the title, and the favicon falls back to the stored entry when
the racing visit has none. Summing is not idempotent, which is safe while
loadAsync runs once from init and is documented as a constraint on adding
any reload.

7 unit tests for the merge contract. SuggestionDismissalTest and
SearchSourceRegistrarTest, which drive this object, still pass 7/7 each.
…es the lock

Follow-up on the merged #795 (issues #762/#764), closing the one review
item it did not include: a save that entered before logout but acquires
fileLock only after clearUserData ran still recreated user_data.json
with the logged-out user's identity - the mutex cannot order a caller
that already passed it.

saveUserData now captures a clear-generation counter before acquiring
the lock and re-checks it inside (delegating to an internal
doSaveUserData seam that takes the entry generation, so the regression
test drives the capture-clear-acquire interleaving deterministically);
clearUserData bumps the counter under the lock. A save entered before
logout observes the bump and skips; a save entered after logout has a
fresh generation and proceeds normally (pinned by the test).

The fence test rides the merged suite's hermetic resetForTesting
fixture. 4/4 + detekt + ktlint green in one run.
… a compound or quoted credential cannot survive
abhinavlenka and others added 22 commits September 24, 2026 15:21
…For hop, not the caller's (#1664)

clientKey used the LEFTMOST X-Forwarded-For entry, which is whatever the
caller sent. Rotating it on every request bought a fresh bucket each
time, so the catalogue and download-info limiters never tripped, and
each request added a map entry until MAX_KEYS forced a clear that
dropped every other client's count too. Writing someone else's address
there spent their budget instead.

Derive the key the way the passkey limiter already does (#1335):
cf-connecting-ip, then x-real-ip, then the RIGHTMOST X-Forwarded-For
entry - the hop the gateway appended for the connection it accepted.

Fixes #1628
* fix(plugin-store): cap catalogue schemas on pageSize, query, tags, githubUrl host and popular-tags limit (fixes #1247, #1249, #1250, #1253)

BossConsole#1247 (list route), #1249 (search route), #1253 (popular-tags)
let callers push unbounded `page`/`pageSize`/`query`/`tags`/`limit` into the
SQL RPCs and the edge function. BossConsole#1250 accepted any URL whose text
contained the substring "github.com" rather than parsing the host. Bound all
five to the same shape the search route already had.

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

* fix(plugin-store): bound /tags/popular, whose limit reached LIMIT unchecked

GET /tags/popular is public and sends the anon key. Its limit reached
`LIMIT p_limit` in get_popular_tags with nothing bounding it anywhere on the
way: not the route (`z.string()...transform(Number)`), not getPopularTags,
which passes it straight to the RPC, and not the SQL function, which is a bare
`LIMIT p_limit` in both the 20260130 and 20260803 definitions.

That allowed two things, and the second is the worse one:

  - an oversized integer asked for an arbitrarily large window;
  - a non-numeric value removed the bound entirely. Number('abc') is NaN,
    JSON has no NaN so the RPC payload carries null - verified:
    JSON.stringify({ p_limit: Number('abc') }) is '{"p_limit":null}' - and
    PostgreSQL documents that a LIMIT evaluating to NULL is LIMIT ALL. So
    ?limit=abc was less bounded than ?limit=99999999.

The fix follows the shape the open ratings fixes (#949, #1045) use for their
routes: take the value as a raw string so the handler owns coercion, and
refuse anything that is not an integer in 1..100 with the route's own 400
before the database is touched. Number.isInteger rejects NaN, fractions and
Infinity in one test. 100 is the ceiling /search already enforces on
pageSize.

Nothing in-repo is affected: PluginStoreClient.getPopularTags defaults to 20
and has no caller passing anything else.

This route is not covered by #1045, which bounds /list and /ratings and does
not touch browse.ts.

Tests follow error-response.test.ts, a route-level harness already on main.
A client whose rpc throws proves refusal happens before the database, since
a request that got through would surface as the route's 500 instead of 400.
A recording client checks the value that actually reaches p_limit, not only
the status.

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

* test(plugin-store): import @std/assert through the import map

The previous commit's test used the inline `jsr:@std/assert` specifier, which
deno lint's no-import-prefix rule flags. The job is green because that lint
step is advisory - the workflow notes it stays non-gating precisely because
existing tests carry this finding - so adding one more instance grows the debt
that keeps it from being gated.

deno.json already maps `@std/assert` to `jsr:@std/assert@^1.0.0`, and
download-hash, publish-authz and publish-org already import it bare. I had
copied error-response.test.ts, which is the one route test still using the
inline form. The bare specifier also pins the major version, which the inline
form did not.

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

* test(plugin-store): match popular-tags validation envelope

The consolidated route uses the OpenAPI Zod validation envelope, so assert the security boundary and database refusal without coupling the test to the formatter.\n\nCo-authored-by: Arjun Singla <singlaarjun28@gmail.com>

* chore: retrigger CI

---------

Co-authored-by: choksi2212 <manaschoksiwork@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Arjun Singla <singlaarjun28@gmail.com>
Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>
…it skips (#1667)

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

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

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

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…nter.post (#1666) (#1670)

* fix(notifications): enforce the inbox's size bounds in NotificationCenter.post (#1666)

#1644 bounded notification_post at the MCP provider, and #1660 put the snippet caps in the
store. Two adjacent fixes on opposite conventions, and this one left a hole: post is
documented as serving the host too, and a HOST-origin post met none of the three bounds.

- MAX_TITLE_CHARS and MAX_MESSAGE_CHARS move to NotificationCenter and are required in
  post beside the existing blank-title check, for every origin.
- A HOST source label longer than MAX_SOURCE_LABEL_CHARS is refused. An agent's label
  keeps #1619's truncation: an agent is not ours to fix, while a host label that long is
  a bug to catch where it is written.
- The MCP tool keeps its check first so its refusal names the field and the limit.
- Tests: over-long title and message refused from both origins with nothing stored; all
  three exactly at their limits accepted; host label refused while an agent's is cut.
  The provider test pins MAX_LIST_LIMIT < MAX_ENTRIES, which the clamp depends on.

Mutation-checked: removing each of the three requires fails a named test. 416 tests
across ai.rever.boss.notifications.* and ai.rever.boss.mcp.* pass; ktlint and detekt clean.

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

* fix(notifications): cut a long host message instead of refusing it; flatten host labels

Review follow-up on #1670.

- A host message over MAX_MESSAGE_CHARS is cut, ending in an ellipsis, never splitting a
  surrogate pair. A host builds its message from dynamic text (an exception message, a
  command's output), and refusing would turn a notice about one problem into a second
  problem on an error path. An agent's message is still refused, because the MCP tool
  checks first and names the limit. Titles are still refused from either origin.
- A host label is flattened to one line like an agent's, and the cap is measured on the
  stamped label, which is exactly what is stored. So trimmed whitespace cannot tip a
  label over.
- post's KDoc states the rule per field and origin, with @throws for the three refusals.
  MAX_ENTRIES states the aggregate bound, about 860,000 characters and more once escaped,
  as SnippetLibraryManager's does.
- Tests: the at-limit test asserts every field is stored intact. New cases cover a host
  label padded with whitespace at the cap, a host label with a newline, a host message
  cut with its ellipsis, and a cut that lands on a surrogate pair.

Mutation-checked: storing the host message uncut, dropping the surrogate guard, not
flattening the host label, measuring it untrimmed, and not refusing the agent message
each fail a named test.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…1043) (#1044)

The absolute-date branch of formatRelativeTime now passes
Locale.getDefault(Locale.Category.FORMAT) to SimpleDateFormat explicitly, matching
the rest of the codebase's date formatters. This is a clarity/consistency change,
not a behaviour fix: the bare SimpleDateFormat("MMM d") constructor already
resolves to the FORMAT-category locale, so the rendered output is unchanged.

Add ProjectCardLocaleTest guarding that the branch honours the FORMAT-category
locale (not the general default and not a hardcoded one), so a future regression
to Locale.getDefault() or a fixed locale is caught.
…ope (#1029) (#1030)

SplitViewOperationsImpl launched every plugin-facing operation on a bare
CoroutineScope(Dispatchers.Main): no SupervisorJob, so one child coroutine failing
cancelled the scope's Job and silently killed every later launch, and the scope was never
cancelled, so a closed window leaked it and any in-flight Main-dispatched work.

- Use CoroutineScope(SupervisorJob() + Dispatchers.Main) so one operation's failure does
  not take down the others.
- Implement the existing DisposableProvider seam and cancel the scope in dispose().
  DefaultPlugin.dispose() now releases the plugin-facing instance (via a named lazy
  delegate, guarded by isInitialized() like the other providers), and the composable that
  owns the BossAppState instance disposes it in a DisposableEffect.

The scope is an injectable constructor parameter (default preserves production) so dispose()
can be tested without a Main dispatcher. The detekt baseline entry for this class is updated
to match its added supertype; it is the same suppression, not a new one.
…ugh atomicWriteText (#1011) (#1022)

Migrate the listed settings/state managers' JSON persistence from a direct
writeText to atomicWriteText (temp file then atomic rename), so an interrupted or
concurrent write cannot leave a truncated or torn file. Not every writer in the
app is migrated yet - only the managers listed in this change.

Rebased onto current dev, which merged #1032's saveMutex serialization for the
four settings managers (FocusMode, Performance, DesktopRunner, DesktopTerminalLink).
This keeps that mutex and does the atomic write inside the lock, in both saveSettings
and the load-time default-file write, for all four. The mutex only orders writes;
atomicWriteText is what makes each write crash-safe, so the load-time read never sees
a torn file even though it stays outside the lock - the mutex comment now says so.
That is the remaining half of #1031 and closes #1659.

Add a test asserting the write goes through the atomic helper: after an update the
settings file is pinned to owner-only 0600 permissions, which atomicWriteText sets
and a plain writeText does not, so the test fails if a call site regresses to a
non-atomic write (the pre-existing no-leftover-temp check passes either way).
…l meets it (#1668) (#1671)

The /list route caps pageSize (#1508), but search_plugins is anon-callable through
PostgREST and passed p_page and p_page_size straight to search_plugins_internal, which
ends in `LIMIT p_page_size OFFSET (p_page - 1) * p_page_size`. So
`POST /rest/v1/rpc/search_plugins {"p_page_size": null}` was LIMIT ALL: every visible
plugin, each with its own version, rating, download and tag subqueries. A negative size
or page raised a LIMIT/OFFSET error, and a huge page overflowed INT.

Both wrappers now clamp before calling the internal function: page size to 1..100 (NULL
-> 20), page to 1..1,000,000 (NULL -> 1). The same shape as get_popular_tags in
20260922160000. Values inside the ranges are unchanged, and the route's own ceiling is
100, so no request the route accepts changes. Clamping in the wrappers avoids restating
search_plugins_internal's hundred-line body, and they are its only callers:
search_plugins_internal is service_role only.

search_plugins is re-granted to anon, because the enforce_explicit_anon_grants trigger
revokes it on CREATE OR REPLACE. search_plugins_for_viewer's service_role-only ACL is
restated.

The pgTAP test uses 150 public plugins. It pins null, zero, negative, one-past and
oversized page sizes, null, zero, negative and INT-max pages, total_count unchanged,
both band edges, the ACL, and the viewer wrapper.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Introduces {{secret:<id>}} references in MCP tool arguments, resolved in an ordered pre-pass ahead of the policy and approval path: denied or feature-off calls never touch the vault, resolution is all-or-nothing, a secret-bearing call always asks (tool or provider ALLOW, session trust and YOLO never satisfy it), the ledger records the original arguments and names the released references, and an optional result scrubber redacts echoed values. The pre-pass's argument parses sit under dev's depth guard, and the invariant suite pins non-disclosure, atomicity, ordering, approval binding, transparency, consistency and bounded work.
… spellings (#1656)

* fix(mcp): stop masking a container uid:gid, and cover the cookie flag spellings

Two findings from the review of #1019 that were left open when it merged.

`docker run -u 1000:1000 nginx` came back as `docker run -u [REDACTED]`. The
basic-auth rule keys on the `user:password` colon after `-u`/`--user`, and a
uid:gid pair has the same shape, so one of the commonest docker and podman
invocations there is had the very thing approval exists to show - which uid a
container is about to run as - taken out of it. No credential is two integers, so
a purely numeric value is now excluded; `-u admin:1234` still redacts, because
only both sides being integers makes it a uid pair. The benign corpus gains the
`-u` twin of the `docker run -p 8080:80` entry that should have caught this, plus
the `--user=` and podman spellings.

`Cookie: session=x` was closed by the header spelling alone. The flag spellings
went through untouched: after `-b` or `--cookie` the cookie's name is whatever the
site chose, so no list of names helps, and the flag has to be the signal. The whole
value is taken, as the long-flag rule already does for `--password`. `-b` also
names a jar file, which is redacted too: telling a path from a cookie needs a
filesystem the sanitizer does not have, and over-masking one curl argument is the
safe direction.

Both are pinned as fuzz shapes rather than examples. Against the sanitizer as it
merged, the two cookie shapes leak 60 cells and the benign corpus fails on the
docker line.

Also from that review, without behaviour changes: the failure message counts the
distinct cells it prints rather than the pre-`distinct()` total, which with
`repeat(3)` reported nine for three; and the generator's KDoc states the blind spot
its alphabet creates, namely that a generated secret can never contain a character
that ends a value, so no cell can discover that `password=Xk9&mQ2` leaves a tail in
the clear.

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

* fix(mcp): gate the short cookie flag on curl, and pin the -b false positives

Review follow-ups. The first version matched a bare `-b` after any program, which
reintroduced exactly the over-masking this change removes for `-u`: `git checkout -b
feature/x`, `ssh -b addr`, `tar -b 20` and `gradle -b file` lost their argument, and
flags that take no value (`cp -b`, `grep -b`, `wget -b`) lost the next positional. The
short flag is now gated on curl the way `-p` is gated on its programs; `--cookie` and
`--cookie-jar` stay unconditional because the name carries the meaning. The benign
corpus gains `git checkout -b`, `cp -b` and `ssh -b`.

The failure count now dedupes by shape and context: each failure string embeds that
repetition's random secret, so plain distinct() collapsed nothing. uidGidValue moves
above basicAuthFlag's KDoc, which it had detached from its declaration.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* ci: run the authenticated-ipc.yml Windows leg only for PRs into main

* ci: run the build.yml Windows leg only for PRs into main

Updated the OS matrix configuration for build-test job to clarify conditions for macOS and Windows runners.

* ci: run the native-file-boundaries.yml Windows leg only for PRs into main

* ci: run the filesystem-limits.yml Windows leg only for PRs into main

* docs: note the Windows legs skip PRs that don't target main
… warning (#991)

Adds buildSrc/settings.gradle.kts with an explicit rootProject.name, so the type-safe project accessors preview stops warning on every Gradle invocation and the build identity is explicit rather than derived.
…eads it (#1658)

* fix(cli): refuse a quote inside a boss.bat argument before anything reads it

Every argument read in boss.bat wraps the value in quotes - if "%~1"=="" -
and cmd substitutes %~1 before it parses the line, so a " in the value
closes that quote early. x"=="x" calc & rem " keeps its & quoted for the
caller's own parse, but turns the first check into
if "x"=="x" calc & rem ""=="" and runs calc.

Capture the raw %* on an echoed REM line (the one place cmd writes it out
without parsing it), read it back through a FOR variable, and refuse any
quote that does not open or close a whole argument. Whole-argument quotes
are all a path or URL needs, and with none inside, no %~N can carry a
quote into the quoted reads, so the rest of the script is unchanged.

Fixes #1617

* fix(cli): scope the boss.bat quote check to what is read, and claim the capture file

Review on #1658:

- status, doctor, mcp and completion pass their arguments to BOSS.exe
  as a bare %*, which cmd re-reads exactly as the caller's line was
  read, and never through %~N. Check only their first argument, so
  `boss mcp invoke tool --args {"q":"x"}` (docs/CLI.md) works again.
  plugin stays strict: it reads %~2 and %~3 before it forwards.
- %RANDOM% is seeded from the clock, so two calls started together
  could share the capture file and each check the other's arguments.
  Claim a directory with md, which fails when it exists, and refuse
  the call if none can be claimed.
- Clear the capture variables before anything is forwarded.
- Run the live cmd.exe probes on the Windows leg of build-test, so the
  mutation checks run in CI and not only on a fork.

Tests: forwarded JSON arguments, first-token and plugin strictness,
escaped > >> | & < on the command line, and eight parallel calls that
must each check their own arguments.

* test(cli): pin that a forwarded argument stays data, and that a taken capture directory is refused

- A payload in argument 2..N of a forwarded command reaches the stub and
  runs nothing, so a future %~2 read in a forwarded command fails the test.
- A copy that always draws the same capture directory name finds it taken
  and refuses, instead of reading the other call's arguments.
- The leftover check only judges capture directories made during the test.
- The for /f read is guarded again, so a missing capture file shows only the
  script's own refusal.
- The refusal points at boss.ps1 for a terminal command that needs a quote,
  and docs/CLI.md says what cmd accepts.
- The Windows CI step runs right after checkout, in one pwsh.
…ly (#741)

A window's Save now binds its live layout to that window's own active Space identity instead of the process-global one, rebinds the window only after the write has landed, and reports a failed write instead of premature success. A per-effect latch coalesces overlapping menu saves into one re-run on the latest layout, the named dialog ignores an overlapping submit, and late callbacks are fenced against window teardown.
* docs(plugin-store): clarify audit RPC failure behavior

Clarify comments regarding JSON input rejection in audit payload.

* fix(plugin-store): log resolved API key audit RPC errors

* test(plugin-store): cover resolved audit RPC failures

* fix(plugin-store): preserve safe audit failure kind
…d both providers (#1552)

* fix(plugins): resolve the Downloads folder one way for the browser and both providers

The browser's save location, the in-process FileSystemDataProvider and the
out-of-process FileSystemDataProviderProxy each resolved the Downloads folder
differently, so a plugin was told a different folder depending on which
process it was loaded into. When ~/Downloads was absent, in-process plugins
got the home folder and out-of-process plugins got a path that did not exist,
with mixed separators on Windows.

Add DownloadsDirectory to plugin-path-utils and route all three through it.
The shared XDG parser also handles ${HOME}, XDG_CONFIG_HOME and last-wins.

DownloadsDirectoryConsistencyTest fails on main and passes here;
DownloadsDirectoryTest covers the per-platform rules on any OS.

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

* fix(plugins): keep the Downloads consistency test out of the Windows ARM64 build, and read XDG edge values as xdg-user-dirs means them

Review follow-up for #1552.

- DownloadsDirectoryConsistencyTest imports FileSystemDataProviderProxy from
  plugin-api-ipc, which Windows ARM64 does not build. It is now excluded with
  SkipListDriftTest when that module is absent. WindowsArm64SourceIsolationTest
  lists it in unconditionalExclusions, so the build-file drift check agrees, and
  gains ai.rever.boss.plugin.ipc. in unavailablePackages, so the next such import
  fails on every platform instead of at release.
- XDG_DOWNLOAD_DIR: an empty last value (how xdg-user-dirs records a disabled
  directory) no longer revives an earlier assignment; a relative value is read
  against home, not the working directory; a value naming home itself falls back
  to the conventional folder.

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

* fix(plugins): treat $HOME/. and $HOME/sub/.. as home in XDG_DOWNLOAD_DIR

Re-review follow-up for #1552. absolutePath does not remove dot segments, so
those forms passed the "not home itself" check and handed back home. The value
and home are now compared after Path.normalize(), which needs no filesystem
access, so existence stays injected in the tests.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#894)

* test(auth): add UserDataStorage merge, clear, and race contract tests

* test(auth): harden the torn-read detector against vacuous passes

A detector thread that died on an unexpected error (or never decoded a
record) would have left the torn-write test green while proving nothing.
Record unexpected detector failures and successful decodes, assert the
thread actually stopped, and add Thread.onSpinWait so the busy-spin stops
fighting the writers for the cores the overlap depends on. Also correct
the lost-update round comment (path coverage is the scheduler's choice,
not the test's claim) and document on clearUserData that the pre-login
pending_wizard_completed marker is deliberately preserved through logout.

* test(auth): satisfy detekt on the hardened torn-write test

Extract the detector iteration and the writer race into private helpers
(LongMethod), drop the second jump statement from the detector loop, and
suppress TooGenericExceptionCaught on the deliberate sentinel catch - the
same convention as BrowserFindTest.

* test(auth): close the remaining vacuity and flake holes in the torn-write race

- replace the broad detector catch with an uncaught-exception handler
  (detekt's TooGenericExceptionCaught stays clean and the net widens)
- restore the transient IOException tolerance dropped in the refactor
- latch the writers to the detector's first disk read: overlap becomes a
  guarantee, killing the successfulDecodes scheduling flake
- assert the detector saw a decoded form beyond the seed, so the test
  cannot pass when no racing write ever landed (e.g. every atomic move
  failing on a platform)
- assert torn-reads before the inconclusiveness guards so a real finding
  is never buried; correct the onSpinWait rationale (PAUSE hint, not yield)
- update the PR body: no behavior changes, only a clearUserData KDoc

* test(auth): fix unscoped async and detekt SwallowedException in the race test

- wrap the 24 racing writers in coroutineScope (a bare async in a suspend
  fun selects the deprecated unscoped overload, which fails compilation)
- name the transient IOException catch '_' so detekt's SwallowedException
  stops flagging the deliberately ignored sharing-violation window

* test(auth): duty-cycle the detector, guard-order it, and always stop it

- parkNanos duty cycle instead of a hot spin: a spinning detector pins a
  core AND keeps user_data.json open a large fraction of the race, which on
  Windows denies the writers' Files.move(., REPLACE_EXISTING, ATOMIC_MOVE)
  delete access so every write no-ops - turning the non-vacuity assertion
  into a misleading loud failure. Sampling on a ~0.1 ms duty cycle still
  takes hundreds of samples across the race, which is plenty for a negative
  no-torn-read invariant, and lets the moves land.
- detector-health guard now precedes the decodedForms non-vacuity guard
  (a detector that died right after its first read would otherwise read as
  'no racing write ever landed')
- stop + join the daemon detector in a finally so a latch-timeout or writer
  throw can't leave it spinning (re-reading storageFile) for the whole
  shared test-worker JVM
- scope the 'cannot flake on scheduling' header claim to the mutex-based
  race tests (2 and 3); test 4 is observation-dependent/best-effort

* test(auth): mark the merge race inconclusive when Windows lands no write (per review)

---------

Co-authored-by: Mihir-Rabari <mihir.rabari@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
…ngs backend (#918)

* fix(auth): persist Supabase session tokens through an encrypted Settings backend

Root cause: install(Auth) left the supabase-kt defaults in place, and its default Settings() resolves to java.util.prefs.Preferences on the JVM, so the session JWTs - including a live refresh token, the highest-value secret in the app - were written as plaintext under ~/.java/.userPrefs (XML), the macOS plist, or the Windows registry: a library-owned store outside the ~/.boss directory this app otherwise keeps owner-only.

Fix: EncryptedSessionSettings, a Settings backend that AES-256-GCM encrypts every value with a fresh 96-bit IV per write into ~/.boss/supabase/session-store.enc, with the random 32-byte key in session-store.key written through atomicWriteText so it lands 0600 on POSIX from its first byte, mirroring the #860 hardening. It is fail-closed: crypto unavailability or a corrupt key aborts construction, never falling back to plaintext. install(Auth) now wires SettingsSessionManager and SettingsCodeVerifierCache onto this backend, and a one-shot migration copies the legacy Preferences plaintext first and then destroys it, so existing sessions survive the upgrade and the live plaintext refresh token is revoked.

Test: EncryptedSessionSettingsTest adds 10 cases, all green: injection of the encrypted backend into the Auth session manager and code verifier cache (assertSame through reflection into the private settings field), string and typed-value roundtrips across a restart, persisted bytes contain no plaintext, the key file is created owner-only on POSIX, remove/clear purge disk, tampered entries decrypt to nothing, legacy Preferences content migrates in with the plaintext destroyed afterwards, and the encrypted copy wins over a stale legacy copy.

Fixes #846

* fix(auth): migrate URL-qualified legacy session keys with dual compatibility

* fix(auth): keep the legacy migration within the detekt return-count rule

Agent review fix for #918 (not contributor credit): migrateLegacyEntry had
five returns, tripping detekt's ReturnCount limit and failing the code-quality
gate. Fold the empty-populated and null-value guards into one check and the
two warn-and-abort arms into a small helper; the fail-closed behavior (keep
the legacy plaintext until the encrypted copy is written) is unchanged.

* fix(auth): fail removals closed, regenerate unusable keys, pin library defaults

- remove/clear destroy the store when the revoking rewrite fails, so a
  sign-out that reported success can never leave a revoked refresh
  token on disk to be auto-loaded at the next start
- an undecodable or wrong-length key file regenerates instead of
  throwing: a corrupt key means sign-in again, not a Supabase client
  that refuses to build
- the key file is created exclusively (Files.createFile) so two
  processes starting against the same directory adopt one key instead
  of each orphaning the other's store
- a drift test pins supabase-kt 3.8.0's default URL-qualified key
  names and its Preferences.userRoot node against the library itself,
  so a dependency bump that changes either fails the build instead of
  silently stranding plaintext tokens in the legacy store

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
Co-authored-by: Mihir-Rabari <mihir-rabari@users.noreply.github.com>
…ce directory (#880)

* fix(mcp): keep workspace ids from selecting files outside the workspace directory

open_workspace and close_workspace (#794) took a caller-supplied workspaceId
ending in ".json" as the file name itself, and the desktop workspace file
manager joined any name onto its directory without confinement. So
open_workspace {"workspaceId":"../x.json"} read and applied a file beside
the directory, and close_workspace with a "workspace-disposable-/../x.json"
id deleted one on Windows, where ".." resolves lexically even when the
component before it does not exist. ~/.boss/mcp-tool-policy.json, the
persisted MCP policy file, is one directory up from the default workspace
directory's parent and is exactly such a file.

WorkspaceFileManagerCommon.isBareFileName states the rule (one component,
no separator of either platform, not "." or "..", no NUL) and
getWorkspaceFilePath enforces it, so every read, write and delete through
the manager is confined; each caller already catches and logs, so a refused
name surfaces as not found or not saved. The provider derives a file name
from an id the same way the file was written (fileNameForId, after
stripping an echoed ".json"), for both tools.

Tests: the end-to-end open case (failed before), a close case that pins
the derived name and the surviving file, and file-manager cases for
delete, load, loadDocument, writeDocumentBlocking and saveWorkspace with
a relative path (delete, load and the writes failed before).

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

* fix(mcp): make the created file name agree with the read one, and say what the bare-name rule means

Review follow-ups on the file-name confinement.

The create path and the read path disagreed for an id ending in `.json`.
`workspaceFileNameFor` strips the suffix before deriving, so `foo.json` reads
`foo.json`; `createIfAbsent` kept the id verbatim, so `saveWorkspace` derived
`foo.json.json` from it. A Space created that way could never be reopened by the
id that made it, and a second `createIfAbsent` silently replaced its layout. The
id is now stripped once where it is minted, so both halves derive the same name.
Pre-existing, but the property this change claims is that the name read is the
name written, and this was the case where it was not.

`isBareFileName` now judges the component Win32 resolves rather than the one it
was handed. Win32 trims trailing spaces and dots, so `".. "` addressed the parent
directory while passing a `!= ".."` test; trimming collapses every
all-dots-and-spaces name to empty, so one emptiness test covers `.`, `..`, `...`
and `".. "` together. `:` is refused (a drive, `C:x.json`, or an alternate data
stream, `notes.json:evil`) instead of relying on the JDK throwing
InvalidPathException at the join, and the control-character test is `isISOControl`
rather than NUL only, matching `isSafeWorkspaceId`. Neither case was an escape to
a file, so this is hardening, not a hole being closed.

The KDoc now says which layer this is: `isSafeWorkspaceId` is the id-level rule at
the tool boundary, `isBareFileName` the name-level rule at the file manager, and
the two differ on purpose (a name may contain `..` inside it, an id may not), so
neither implies the other. `getWorkspaceFilePath`'s `expect` declaration documents
the throw, which is the contract callers outside this class meet.

Tests: an id ending in `.json` creates one file, named as the read path derives
it, and reopens by either spelling; the bare-name rule accepts four real names and
refuses thirteen shapes including `...`, `".. "`, `C:x.json` and `x.json:stream`;
an empty name addresses the workspace directory and is refused on all four
entry points. The two provider tests that dev's `isSafeWorkspaceId` now refuses
first say so, and assert that message, so they read as regression guards for that
gate rather than as cover for this change; the file-manager test is the one that
fails without `isBareFileName`. Reverting the two fixes fails exactly the two new
tests (`must refuse: '...'`, `round-trip.json.json`). 255 tests green; ktlint and
detekt clean.

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

---------

Co-authored-by: Atharv Gaur <atharvanna71@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…es (#957)

* fix(mcp): share the startup-command refusal across open_workspace modes

open_workspace's id mode refuses to open a saved Space whose layout carries
terminal initialCommand entries - those commands were not visible in the
invocation's approval arguments - but the path mode re-entered the same
saved-Spaces machinery with no gate: opening a project path applied any saved
Space registered for it (matchExistingSpace rule 3) and typed its stored
terminal startup commands into a shell, unapproved. The refusal could be
walked around by passing a path instead of an id.

Both modes now route through one shared refusal (initialCommandsRefusal): the
same message, the same shipped-template exemption, and the same placement
before anything is applied - before the path mode's reuse fast path for the
same reason the id mode's gate sits before its already-active return. The
operator's doors are unchanged: the workspace UI loads a command-carrying
Space behind its confirmation prompt, and open_terminal still types one
command an invocation the risk gate has actually seen.

Adversarial coverage: a saved Space whose terminal writes a marker file is
refused by path entry with the id mode's refusal word for word and is never
applied to the window, while a saved Space without startup commands still
re-enters with its terminal intact.

Fixes #920

Windows follow-up (first CI run caught it): the two new path-mode tests failed on
windows-latest because matchExistingSpace compared project paths by raw string. The
request arrives canonicalized (C:\dir) while a saved Space's stored spelling can
legitimately differ (C:/dir - another save surface, or another OS in a synced
workspace file), so rule 3 missed the Space: the refusal silently missed with it,
and the benign re-entry minted a duplicate instead of reusing. The lookup now uses
path identity (matchesProjectPath over TabPaths.pathsMatch, the same definition
open tabs already trust), with a spelling-insensitivity unit test; on macOS/Linux
the spellings already agreed, so nothing changes there.

* test(mcp): register path-mode Space fixtures after the manager's first load

The workspaceManager singleton loads its picker list asynchronously once
per process, and that first load REPLACES the whole list. A test that
registered a fixture right after first touching the singleton could
have its registration swapped out: matchExistingSpace then saw nothing
for the project path, path mode minted a fresh Space, and the test saw
"opened" where a refusal or reuse was expected. Linux runners usually
finished the load first; the Windows CI run (35360957645) did not, which
is the flake behind that red check.

Both path-mode tests that register a Space now await the singleton's
first non-empty workspaces value before registering, so the
registration lands after the load and can no longer be replaced under
it. No assertion is weakened, and the gate logic is untouched.

The registered ids are also dropped from the process-wide singleton in
tearDown so later test classes in the same JVM inherit no fixture.
WorkspaceManager has no unregister door - deleteWorkspaceById removes a
row only when its own file manager deleted the file, which is not where
a test fixture lives - so the helper reaches the backing flow directly,
the same way other desktop tests reset otherwise-final private state.

* test(mcp): wait for the merged first load, not a non-empty list

The first-load wait `first { it.isNotEmpty() }` can be satisfied by a
pre-load registration: the create_workspace tests in this class register
into the workspaceManager singleton with no wait and JUnit method order
is not fixed, so if one runs first the wait returns before the async disk
load lands, and that load then replaces the list - dropping the fixture
exactly as in the Windows flake. Wait instead until every shipped layout
id is present, which only the merged post-load list satisfies.

Review follow-up from the 957c15e pass.
)

Adds TerminalService.CloseInput, backed by TerminalSession.closeStdin(), which delivers
EOF on a session's stdin without terminating it. A stdin-consuming one-shot command
(sort, grep, cat with no args, or a pipeline ending in one) otherwise blocks on read()
forever, and its run_command exit chunk is never produced.

Rebased onto dev's reworked input path (#939, #1527):

- closeStdin() takes the same input mutex as send(), through the same bounded
  acquireInputLock, so a close never races a write on the same fd, and a caller behind
  a stalled writer is answered RESOURCE_EXHAUSTED "Terminal input is busy" with a
  retry-after, exactly as a queued send is.
- A deliberate close is tracked apart from dev's inputClosed (set when a write fails), so
  a send after it reads "Terminal input has been closed", not a broken pipe.
- IpcVersion.CURRENT moves to 1.4.0 (1.3.0 is now #1144's NodeSkipped), and
  :boss-ipc's module version follows, so verifyIpcVersionConsistency passes. The history
  note says the bump is not a capability signal: an older host answers CloseInput with
  UNIMPLEMENTED, which callers must treat as "not supported".
- inputBusy() moved to file scope and requireUsableInput() folded to one throw, to stay
  within detekt's TooManyFunctions and ThrowsCount limits.

Tests: TerminalCloseInputTest (5) covers EOF delivery with and without input, an
idempotent second close, a send refused after close while the process is still alive
(drain-stdin-hold fixture), and the busy answer while a 1 MiB write holds the lock,
bounded so a regression fails instead of hanging. Mutation-checked: an unbounded wait in
closeStdin, or dropping the closed-on-request check, each fail a test.

Refs #928
…che writes (#1679)

* docs(ci): record dev-push cache seeding and read-only PR caches

* ci: seed shared Gradle caches from dev, read-only caches on PRs

PRs stop writing PR-scoped Gradle caches (cache-read-only on pull_request); a ubuntu-only build on dev pushes seeds the shared caches PRs into dev restore.

* build(test): run desktop tests in two forks

* Revert maxParallelForks: forks would share test-home (per-fork homes needed first)

* docs(ci): fix stale validation sentences, pushes-only cache writes

* ci: only pushes write Gradle caches (dispatch runs read-only too)
@github-actions

Copy link
Copy Markdown
Contributor

Claude diff review of 4f21b91 (no tests executed).

Review of PR #1477 @ 4f21b91 (→ dev)

Scope reviewed: the diff only. I have not run the build or tests and cannot see the surrounding bodies of removeDownload, scheduleSave, saveImmediately, mergeRecordedPages, mergeDismissedSuggestions, FileSystemUtils.cleanupPartialFile, so findings that depend on those are marked uncertain.


Confirmed defects

1. DownloadDataProviderImpl: a rejected cancel now creates a permanently unremovable entry plus orphaned bytes

composeApp/src/desktopMain/.../DownloadDataProviderImpl.kt (~lines 98–115):

val cancelled = engine.cancel(id)
val settled = downloadManager.getDownload(id)
check(cancelled || settled == null || settled.isTerminal) {
    "The download is still active. Could not cancel it; retry removal."
}

The code this replaces existed precisely because the engine may never emit a follow-up event after a rejected cancel — the deleted comment says so verbatim: "on a rejected cancel no event arrives at all", which is why it also called FileSystemUtils.cleanupPartialFile(...) unconditionally.

Under the new code, if engine.cancel(id) returns false and the tracked status never transitions to terminal (exactly the case the old comment describes: the engine has already dropped the id, so no listener event will ever update DownloadManager), then:

  • check fails on every attempt → removal fails forever, with no escape hatch;
  • the partial file is never cleaned up (the cleanupPartialFile fallback is now unreachable on that branch).

The new test encodes this as desired behaviour, and in doing so demonstrates the dead end: FakeDownloadEngine(liveIds = emptySet()) means the engine does not know d4, cancel returns false, and the manager keeps DOWNLOADING — so the asserted "retry" in the test name will fail identically on every retry (DownloadDataProviderImplTest.kt, rejected cancellation keeps the active download and its bytes for retry, ~lines 83–98). Suggest distinguishing "engine rejected because it never owned the id" (safe to drop + clean up) from "engine refused while actively writing" (retain), or adding a forced-removal path.

2. Path.of / Files.deleteIfExists turn previously tolerated states into permanent failures

Same file, lines ~97 and ~112:

Files.deleteIfExists(Path.of(download.destinationPath))
  • Path.of throws InvalidPathException (unchecked) for paths the old File(...) constructor accepted (embedded NUL, Windows-illegal characters in a path persisted by an older build). Combined with the new "filesystem failure retains the entry" contract, such a row can never be removed from the UI.
  • Non-transient IOExceptions (AccessDeniedException on a read-only directory, Windows sharing violation from an external process) are now also permanent, while the user-visible message asserts the opposite: "retry removal."
  • The logger.warn(LogCategory.FILE, "Failed to delete file", …) call was dropped, so these failures now have no log record at all — only a Result whose surfacing I cannot verify from the diff.

3. check() used for an expected runtime race

Lines ~106–108. check signals a programmer-contract violation and throws IllegalStateException; here it models a normal engine race and its message is written as user-facing copy. If any upstream layer treats IllegalStateException as a bug (crash reporting, rethrow outside the runCatching that the tests imply exists), this becomes a crash rather than a failed operation. An explicit typed failure (Result.failure(DownloadStillActiveException(...))) is more honest and greppable.

4. RecentPagesLoadGuard ticket can leak, and the leak grows unboundedly

RecentBrowserPagesManager.kt:

internal val initialLoad: Job = loadGuard.begin().let { ticket -> scope.launch { loadAsync(ticket) } }

and

internal suspend fun loadAsync(
    ticket: RecentPagesLoadGuard.Ticket = loadGuard.begin(),
    …
) = withContext(Dispatchers.IO) {
    try { … } catch (e: Exception) { … } finally { loadGuard.end(ticket) }
}

begin() happens outside the withContext block, but end() is inside it. If the launched job or the caller's job is already cancelled when withContext is entered, the block body never runs, the finally never executes, and the ticket stays in active permanently. Every subsequent loadGuard.remove(...) then appends another predicate to that dead ticket's removals list (RecentPagesLoadGuard.kt:21), which is never cleared except by clear() — unbounded list growth for the process lifetime, and any future load sharing that stale state is irrelevant only because the ticket is per-load. Fix: register the ticket inside the same try scope as the end, or use a try/finally around the launch in initialLoad.

5. Cheap-but-eager default argument does filesystem resolution on every load

historyFile: File = BossDirectories.resolve("browser-history.json") is evaluated on function entry, i.e. also on the common path where settingsFile.exists() and the history file is never touched. Minor, but it is a behavioural change from the previous lazily-resolved val browserHistoryFile = BossDirectories.resolve(...) inside the bootstrap branch, and resolve may create directories.


Uncertain observations

6. Removal predicates use raw URL equality while the manager has a canonical key notion

removePage:

loadGuard.remove({ it.url == url }) {
    _recentPages.update { pages -> pages.filter { it.url != url } }
}

The same file canonicalises elsewhere (promoKeys / canonicalUrlKey(it.url)). For the settings-file path the loaded URL is byte-identical to the recorded one, so exact match is fine; for the bootstrap path the rows come from browser-history.json and may differ only in canonical form (trailing slash, fragment, scheme case). If so, a dismissal during a bootstrap load will not suppress the loaded row and the dismissed page reappears — the exact bug this PR sets out to fix. Cannot confirm without canonicalUrlKey/mergeRecordedPages semantics.

7. The guard protects pages but not _dismissedSuggestions

clearAll() calls loadGuard.clear { _recentPages.value = emptyList() }, then mutates _dismissedSuggestions outside the lock, while loadAsync merges loadedDismissals into that flow with no ticket involvement. Correctness therefore rests entirely on mergeDismissedSuggestions being union-like. That is probably true today, but the asymmetry is undocumented and any future "undismiss" operation would race exactly the way removePage/clearAll used to race the page list.

8. Deleting a directory at destinationPath is now enshrined by test

failed file removal retains tracking and can be retried asserts assertFalse(directory.exists()) after the child file is removed — i.e. a "remove download" action deletes a directory if destinationPath points at one. File.delete() behaved the same way, so this is not a regression, but there is still no containment check that destinationPath lies under the downloads root before the unlink. Worth a look given the path originates from Chromium.

9. Possibly missing import

DownloadDataProviderImplTest.kt now uses assertNotNull (lines ~95, ~223, in the new tests). The import hunk only adds java.nio.file.Files, and the visible context stops at kotlin.test.assertFalse — the position where assertNotNull would sort. Worth confirming the import already exists; otherwise this does not compile.

10. Nested runBlocking inside cancel() in the new fakes

FakeDownloadEngine(emptySet(), onCancel = {
    runBlocking { manager.updateStatus("race", DownloadStatus.COMPLETED) }
})

cancel() is invoked from inside the provider's coroutine, so this blocks a dispatcher thread while awaiting updateStatus. If DownloadManager guards state with a Mutex and the provider context has limited parallelism, this is a deadlock candidate rather than a deterministic test. Cannot confirm without provider(...)'s context and updateStatus's implementation.


Test gaps

  1. The stated motivation is untested. The comment "Register before launch: a Clear before the IO coroutine starts must also win" is the whole reason begin() was hoisted out of scope.launch, yet all four cases in RecentPagesDismissalDuringLoadTest gate on withTimeout(2_000) { readStarted.await() } — the dismissal always lands after the read has begun. The pre-start window has no coverage.
  2. No unit tests for RecentPagesLoadGuard itself. No coverage for: remove after end (must be a no-op — AGENTS.md explicitly claims "not permanent URL tombstones"), clear followed by remove on the same ticket, two concurrent tickets where only one sees a removal, or predicate accumulation.
  3. No test that tickets are released. Nothing asserts that a removal issued after a load completes does not filter a subsequent load — the property the new AGENTS.md paragraph promises.
  4. retireVisits was rewritten to route through the guard (predicate extracted, loadGuard.remove(retire) { … }) with no new test; in particular nothing verifies the removed counter still matches the committed list now that the update runs under the guard lock.
  5. Dismissal set not asserted. verifyOutcome checks only recentPages and the serialized file for URL substrings; dismissedSuggestions behaviour across the load race is unverified even though clearAll mutates it outside the guard.
  6. Download side: no test for AccessDeniedException/permission failure (only DirectoryNotEmptyException), none for an invalid destinationPath (InvalidPathException), and none asserting what happens on the second rejected-cancel retry — which is where finding ⬆️ actions:(deps): Bump supabase/setup-cli from 2 to 3 #1 bites.

Test-hygiene risks in RecentPagesDismissalDuringLoadTest

  • It mutates process-wide singleton state (manager.settingsFile) and restores private fields by reflection:

    RecentBrowserPagesManager::class.java.getDeclaredField(name).apply { isAccessible = true }

    A rename of _recentPages/_dismissedSuggestions fails at runtime with NoSuchFieldException inside the finally, leaving the singleton pointed at a deleted temp directory for every later test in the JVM. There is no compile-time coupling to catch this.

  • The four tests each hold the singleton hostage across a paused IO read; any other test in the same JVM touching RecentBrowserPagesManager concurrently (or a maxParallelForks > 1 configuration) will interfere.

  • withTimeout(2_000) { while (…) yield() } is a busy loop with a load-sensitive deadline — a plausible CI flake source.


Non-blocking notes

  • clearAll(): Job — the returned Job is presumably ignored at UI call sites, so save failures remain silent; also note the asymmetry that clearAll captures val target = settingsFile while bootstrapFromBrowserHistory's saveImmediately() and scheduleSave() still read the mutable field late. That capture exists only to make the test's field-swap safe — a test-shaped seam in production code, as are the read/historyFile parameters.
  • RecentPagesLoadGuard.end has an inferred Boolean return (active.remove(ticket)); an explicit Unit return would stop callers from reading meaning into it.
  • AGENTS.md states "Keep the guard lock away from disk IO" — the diff does honour this (read(...) is invoked outside publish), and publish/remove/clear hold the monitor only across non-suspending StateFlow updates. Good.

@github-actions

Copy link
Copy Markdown
Contributor

Claude diff review of 4f21b91 (no tests executed).

Review of PR #1477 @ 4f21b91 → dev

Diff-only review. I did not run anything; everything below is inferred from the diff text alone, and functions whose bodies are outside the diff (FileSystemUtils.cleanupPartialFile, saveImmediately, flushPendingSaves, canonicalUrlKey, mergeRecordedPages, DownloadManager.updateStatus, the runCatching wrapper in removeDownload) are treated as unverified. Line numbers are approximate, derived from the hunk headers.


A. DownloadDataProviderImpl.kt (hunk @@ -91,42 +92,27 @@)

A1. Path.of(...) introduces an unchecked InvalidPathException where File(...) never threw — confirmed behavior change

// ~line 95
Files.deleteIfExists(Path.of(download.destinationPath))

File(String) accepts essentially any string; Path.of throws InvalidPathException for paths containing a NUL byte, or reserved/invalid characters on Windows (:, ?, *, …), e.g. a destinationPath persisted by an older build, imported from another OS, or produced from a hostile Content-Disposition filename. Under the old code such an entry removed cleanly (exists() → false → warn-free proceed). Now the whole removeDownload fails, and since the tracking entry is only dropped after this block, the entry becomes permanently unremovable from the UI — there is no force/force-forget path anywhere in the diff. Same applies at the second call site (~line 112, settled.destinationPath).

A2. Any filesystem failure now blocks list removal, with no escape hatch — regression risk

The comment states the intent explicitly ("Other filesystem failures must retain the entry so the user can retry or reveal it"), so this is deliberate, but the practical consequences are worth weighing before merge:

  • Windows AccessDeniedException when the finished file is open in another application (very common for PDFs/media) → the user cannot remove the row from their download list until they close the other app.
  • Read-only/no-write-permission parent directory, vanished network share mount point, immutable flag → same.

Previously this was logger.warn(...) + removal (the deleted lines 94-98). Coupling "remove this row from my list" to "delete the file from disk succeeded" is a UX decision; if it stands, a "remove entry only" affordance or a bounded retry seems needed. Not covered by the new tests either way (the new tests only prove the entry is retained, never that a user can eventually get rid of it without fixing the filesystem).

A3. Diagnostics regression + check used for an expected runtime race — confirmed

// ~lines 105-108
val cancelled = engine.cancel(id)
val settled = downloadManager.getDownload(id)
check(cancelled || settled == null || settled.isTerminal) {
    "The download is still active. Could not cancel it; retry removal."
}

The deleted code logged both the benign race (debug, with id) and the genuinely odd case (warn, with id + status). Both are gone. The replacement throws IllegalStateException whose message carries no id and no status, so if the caller only propagates Result.failure, this failure mode is now unobservable in logs. Additionally, check is the Kotlin idiom for programmer error, and the message is written as end-user copy ("retry removal") — if that string is surfaced in UI, an IllegalStateException is the wrong carrier; if it is not surfaced, the user gets a silent failure. Recommend an explicit domain exception plus retained logger.warn with id/status.

A4. The check re-reads the manager synchronously after engine.cancel — uncertain, but likely to make removal flaky

engine.cancel is a boolean command; the manager's state is presumably updated by an asynchronous engine listener (that is exactly what the deleted comment described: "the download reached a terminal state between the lookup and the cancel"). If cancel is rejected because the engine already released the download but the listener event has not landed yet, settled.isTerminal is still false and the check fires — i.e. the old, explicitly documented benign race is now converted into a user-visible failure, and if the engine never emits a terminal event for a handle it already released, the entry is stuck forever (A1/A2 again). I cannot confirm the engine/listener ordering from this diff; please confirm, and consider a short re-read/await instead of a single immediate poll.

A5. Deletion contract is asymmetric between branches — uncertain

// ~lines 111-115
if (settled?.status == DownloadStatus.COMPLETED) {
    Files.deleteIfExists(Path.of(settled.destinationPath))   // failure aborts removal
} else {
    FileSystemUtils.cleanupPartialFile(download.destinationPath)  // failure presumably swallowed
}

The comment claims "the same deletion contract", but only the COMPLETED branch actually enforces it. If cleanupPartialFile is best-effort (its body is not in the diff, and the old code relied on it being tolerant of Chromium still holding the file), then a partial file that cannot be deleted still drops the tracking entry — the exact outcome the new COMPLETED contract forbids. Worth making explicit one way or the other.


B. RecentPagesLoadGuard.kt / RecentBrowserPagesManager.kt

B1. The guard's removal identity differs from the merge's identity — most likely real defect

// RecentBrowserPagesManager.kt ~line 479
loadGuard.remove({ it.url == url }) {
    _recentPages.update { pages -> pages.filter { it.url != url } }
}
// RecentPagesLoadGuard.kt ~line 40
loaded.filterNot { page -> ticket.removals.any { it(page) } }

The predicate is an exact string comparison on url, but the thing it is now filtering is the disk-loaded row set, and the merge it protects (mergeRecordedPages, plus promoKeys) is keyed by canonicalUrlKey. So if the persisted row's URL differs from the string passed to removePage by anything canonicalization normalizes (trailing slash, scheme/host case, fragment, www.), the dismissal filters the in-memory copy but not the loaded copy, and mergeRecordedPages re-introduces the dismissed page. That is precisely the bug this PR sets out to fix, surviving for non-identical URL spellings. The new tests use byte-identical URLs throughout (removed/retained literals in RecentPagesDismissalDuringLoadTest.kt ~lines 34-40), so this case is untested. Confirming canonicalUrlKey's aggressiveness would settle whether this is a defect or a non-issue; as written the guard and the merge disagree on page identity.

B2. begin() outside the coroutine has no cancellation-time release — leak hazard

// RecentBrowserPagesManager.kt ~line 242
internal val initialLoad: Job = loadGuard.begin().let { ticket -> scope.launch { loadAsync(ticket) } }

end(ticket) only runs in loadAsync's finally. If the launched coroutine never enters its body (scope already cancelled, dispatcher shut down, or the job cancelled before first dispatch), the ticket stays in active permanently. remove() then appends a lambda to that zombie ticket's removals on every removePage/retireVisits for the process lifetime — an unbounded list of closures, each capturing a URL/cutoff. Registering before launch is the right call for correctness (the comment is accurate), but it needs initialLoad.invokeOnCompletion { loadGuard.end(ticket) } (or equivalent) to make release unconditional.

B3. mutation/merge run while holding the monitor — uncertain, low probability

remove, clear and publish (RecentPagesLoadGuard.kt ~lines 17-43) all invoke the caller-supplied lambda inside synchronized(lock), and every one of those lambdas writes a MutableStateFlow. Setting a StateFlow value can resume collectors; a collector running on an immediate/unconfined dispatcher that re-enters removePage/clearAll on the same thread is fine (monitors are reentrant), but a collector on another thread that blocks waiting for work needing this lock would deadlock. The AGENTS.md claim ("no filesystem work holds this lock") is satisfied by the diff, but publish does hold the lock across updateAndGet, which retries under contention. Cheap mitigation: compute surviving under the lock, run merge outside it.

B4. Cleared bootstrap still writes and logs a wrong count — confirmed, minor

// RecentBrowserPagesManager.kt ~lines 325-333
if (recentPages.isNotEmpty()) {
    loadGuard.publish(ticket, recentPages) { surviving -> _recentPages.update { ... } }
    saveImmediately()
    logger.debug(..., "Bootstrapped pages from browser history", mapOf("count" to recentPages.size))
}

The emptiness check is on the unfiltered list. When the ticket was cleared (or every entry was removed), publish merges nothing, yet the code still performs an immediate disk write and logs count = <pre-filter size> — a misleading log line and a needless write on the "Clear during startup" path. Gate on the publish result instead.

B5. clearAll captures the target file while other save paths presumably do not — minor, test-only today

// ~lines 573-575
val target = settingsFile
return scope.launch { saveImmediately(target) }

scheduleSave()/bootstrap's saveImmediately() (no argument) appear to read the settingsFile field at save time. With settingsFile being a mutable var that the new test reassigns, two in-flight saves can target two different files. Harmless in production (the field never changes), but it makes the test's ordering assumptions load-bearing. I could not verify the saveImmediately(File) overload exists — its signature is not in the diff.

B6. Dismissal set is still unguarded

_dismissedSuggestions is merged/updated outside the ticket mechanism (loadAsync ~line 272, clearAll ~line 571). This appears safe because the operation is union-only/monotonic, but it means a dismissal recorded during load is not covered by the new invariant, and nothing in the new tests asserts it survives (see C2).


C. Test gaps

  1. No unit test for RecentPagesLoadGuard itself. All coverage is end-to-end through the singleton. Untested guard behaviors that the implementation deliberately encodes: remove skipping already-cleared tickets (active.filterNot { it.cleared }, ~line 20); clear wiping accumulated removals (~line 28); two concurrent tickets with different removal sets; end making the ticket inert (the AGENTS.md "not permanent tombstones" claim — nothing asserts a subsequent load re-surfaces a page removed during an earlier load).
  2. Dismissal persistence during load is unasserted. RecentPagesDismissalDuringLoadTest.verifyOutcome (~lines 84-98) checks recentPages and the pages payload only; it never inspects dismissedSuggestions, so a regression that drops the dismissal record (letting the page return as padding) would pass.
  3. URL canonicalization variants are untested (see B1).
  4. Download side: no test for a cancel that is accepted while the download stays non-terminal (the cancelled == true + settled.isTerminal == false path, which skips the check and deletes the partial), and no test for an undeletable/invalid destinationPath shaped like A1.
  5. Test-isolation hazard (uncertain): RecentPagesDismissalDuringLoadTest joins initialLoad (which reads the real user file), reassigns the shared settingsFile, and restores private MutableStateFlows by reflection (~lines 101-108). Any other test class in the same JVM touching RecentBrowserPagesManager, or a parallel runner, will interfere; the four cases also depend on flushPendingSaves() observing a save that recordPageVisit may schedule from a separate coroutine (~lines 61-66), which looks order-sensitive.
  6. Nested runBlocking inside FakeDownloadEngine.onCancel (DownloadDataProviderImplTest.kt ~lines 255, 275, and the new onCancel hook ~line 341) blocks the dispatcher thread from inside engine.cancel, which is itself called from inside a coroutine. If DownloadManager.updateStatus ever needs that thread (or a mutex held by the outer coroutine), this deadlocks rather than fails. A synchronous test double or an unconfined test scheduler would be safer.
  7. already missing completed file can be removed repeatedly (~lines 233-244) does not test what its name says: the second removeDownload takes the unknown-id path because the entry was already dropped, so only the first call exercises deleteIfExists on a missing file.

D. Documentation

AGENTS.md (+2485-2493) accurately describes the mechanism, with one caveat: "Release tickets after loading" is only true when the load coroutine actually runs (see B2), and "Clear and removal predicates update that ticket alongside the in-memory mutation" is literally true but the identity used for those predicates differs from the merge's (see B1).


Suggested blocking items

  • B1 (predicate vs. canonical-key identity) — confirm or fix; it undermines the PR's stated goal.
  • A1 (InvalidPathException → permanently unremovable entry) and A2/A4 (no escape hatch when deletion/cancel keeps failing).
  • B2 (unconditional ticket release).

Nice-to-have before merge

A3 (restore id/status logging), A5 (make the two branches' contracts consistent), B3 (don't hold the monitor across flow mutation), B4, and test gaps C1–C3.

@github-actions

Copy link
Copy Markdown
Contributor

Claude diff review of 5dd4c9d (no tests executed).

Review of PR #1477 @ 5dd4c9d

Scope note: this is a diff-only review. I could not inspect removePage's full body, scheduleSave/saveImmediately/flushPendingSaves signatures, FileSystemUtils.cleanupPartialFile, or the runCatching/Result wrapper in removeDownload; several findings below are conditional on those. I did not run or verify any tests.


Confirmed defects / behavior regressions

1. Files.deleteIfExists(Path.of(...)) converts unrelated failures into a permanently unremovable entry

composeApp/src/desktopMain/.../DownloadDataProviderImpl.kt (new ~L95‑97, and the race branch ~L112‑114)

Files.deleteIfExists(Path.of(download.destinationPath))

Two distinct behavior changes here, only one of which is documented by the new comment ("Other filesystem failures must retain the entry"):

  • Path.of throws, not just delete. Path.of(String) throws InvalidPathException (unchecked) for malformed paths (NUL bytes, illegal characters on Windows). The old code (File(download.destinationPath)) never threw for a bad path string. Because the new code is inside whatever runCatching produces the Result the tests assert on, a download row with a malformed destinationPath is now permanently unremovable — every retry re-throws. That is a hard regression with no user escape hatch.
  • No force-remove path. Previously a failed delete logged a warning and still dropped the tracking entry. Now any IOException (file open/locked on Windows, read-only volume, DirectoryNotEmptyException, AccessDeniedException) blocks list removal forever. The new test only covers the case where the user can make the delete succeed (failed file removal retains tracking and can be retried, DownloadDataProviderImplTest.kt new L211‑232). There is no "remove from list only" affordance in the diff, so a user whose file is locked by another process is stuck with the row. If "retain on failure" is the intended product contract, it needs an escape hatch and/or a distinction between transient and permanent failures.

2. Diagnostics were deleted, not replaced

Same file, removed L104‑120: the debug("Download settled before the cancel reached the engine") and warn("Engine did not accept cancel…", id, status) calls are gone, replaced by

check(cancelled || settled == null || settled.isTerminal) {
    "The download is still active. Could not cancel it; retry removal."
}

The warn was the only signal that the engine is rejecting cancels; now the failure is an IllegalStateException message with no id/status context and (as far as the diff shows) no log line. Recommend logging with id/status before throwing. Also note check is being used for an expected race outcome rather than a programmer-error assertion, and the message is phrased as user-facing copy — if any caller surfaces Result.exceptionOrNull()?.message this is UI text buried in an assertion.

3. Inconsistent deletion contract between the completed and the partial path

Same file, new L113‑116: when cancellation succeeds, FileSystemUtils.cleanupPartialFile(...) runs and the entry is removed regardless of whether the partial bytes were actually deleted (best-effort, per the existing util), while a completed file's delete failure now aborts removal. So "remove" can still silently leave untracked partial garbage on disk while refusing to remove a row whose final file is merely locked. Worth deciding on one contract; at minimum document the asymmetry.

4. Guard ticket can leak, and leaked tickets accumulate predicates without bound

RecentBrowserPagesManager.kt new L240‑241:

internal val initialLoad: Job = loadGuard.begin().let { ticket -> scope.launch { loadAsync(ticket) } }

begin() runs outside the coroutine; end(ticket) runs only in loadAsync's finally (new ~L288‑290). If the launched coroutine is cancelled before its body starts (scope cancellation at shutdown, or scope.launch on a cancelled/replaced scope), loadAsync never executes and the ticket stays in active forever. Every subsequent removePage/retireVisit then does active.forEach { it.removals.add(predicate) } (RecentPagesLoadGuard.kt L21), so Ticket.removals grows unboundedly for the process lifetime — a slow leak plus O(n) cost on each dismissal. Fix: scope.launch { ... }.also { it.invokeOnCompletion { loadGuard.end(ticket) } }, or move begin() inside the coroutine and accept the (now-closed) ordering concern differently.

5. RecentPagesLoadGuard runs caller-supplied mutations while holding the lock — the documented invariant is only convention

RecentPagesLoadGuard.kt L17‑43: remove, clear and publish all invoke their mutation/merge lambda inside synchronized(lock). Today those lambdas are MutableStateFlow writes, but MutableStateFlow.value = / update can resume collector continuations inline (unconfined, or Main.immediate when called from the UI thread). That means arbitrary collector code (recomposition, listeners) can execute under this lock. AGENTS.md (new L2485‑2492) asserts "Keep the guard lock away from disk IO" as an invariant, but nothing in the type enforces it — the lock is held across a caller-provided lambda. This is a latent deadlock/latency hazard rather than a demonstrated bug, but the invariant should be enforced (e.g. compute under lock, mutate outside) or the risk documented at the lambda parameters.


Uncertain observations (need local verification)

6. Possible compile breaks from signatures not shown in the diff

  • RecentPagesDismissalDuringLoadTest.kt L72 calls manager.removePage(removed).join(). The diff never changes removePage's return type (only its body, ~L476‑481) and its own comment says the mutation is "applied on the caller's thread, not inside scope.launch". If removePage still returns Unit, this test does not compile. Only clearAll() was changed to return Job (L565, L574). Please confirm removePage returns a Job/awaitable.
  • DownloadDataProviderImplTest.kt newly uses assertNotNull (modified L95, new L221, etc.) but the diff adds only import java.nio.file.Files. Confirm kotlin.test.assertNotNull is already imported.
  • saveImmediately(target) (L575) and flushPendingSaves() (test L38) must exist with those shapes; neither is in the diff.

7. clearAll() mutates _dismissedSuggestions outside the guard lock

RecentBrowserPagesManager.kt L566‑573: loadGuard.clear { pages = empty } holds the lock, then _dismissedSuggestions.update { it + promoKeys } runs unlocked. An in-flight load's dismissal merge (L272‑276) is not guarded at all. This appears safe because both sides are union-only (mergeDismissedSuggestions), so nothing is lost — but that safety is implicit and undocumented, and it depends on mergeDismissedSuggestions semantics I can't see. Worth a comment stating the union invariant, since a future non-monotonic change there would silently reintroduce the "Clear doesn't stick" bug for promo cards.

8. Production API widened for test seams

loadAsync(ticket = …, read: suspend (File) -> String = { it.readText() }, historyFile: File = BossDirectories.resolve("browser-history.json")) (L252‑256). Injecting a read function and a history path into a production singleton is workable but means production now has a code path (ticket default loadGuard.begin()) that only tests exercise, while the real wiring passes an explicit ticket. Low risk, but the two entry shapes diverge, so a bug in one is not covered by tests of the other (see gap 12).

9. Double publication on the error path

L280‑285: on decode failure after publish has already run, bootstrapFromBrowserHistory(ticket, …) is called with the same ticket and publishes again. Re-filtering is idempotent and the merge is union-like, so I believe this is benign — but two publish calls per ticket is an unstated assumption of RecentPagesLoadGuard.publish, which reads mutable ticket state each time. Worth asserting single-use or documenting re-entrancy.

10. Race window for settled == null + successful cancel

New L110‑116: when settled == null (engine listener already removed the entry) the code falls into the else branch and calls cleanupPartialFile(download.destinationPath). If the download actually completed in that window, the entry is gone and we call the partial-cleanup helper on a final file path. This is pre-existing behavior (the old code called cleanupPartialFile unconditionally), so not a regression — but the new settled?.status == COMPLETED branch was added precisely to handle "completion wins the race", and it does not cover the settled == null variant of the same race.


Test gaps

  1. No direct unit tests for RecentPagesLoadGuard (RecentPagesLoadGuard.kt, 44 new lines, all new logic). Untested branches: remove skipping already-cleared tickets (L21), clear wiping removals (L28‑31), multiple concurrent tickets, end removing a ticket so later dismissals are not recorded, and publish with a predicate that matches nothing. All four scenarios currently go through the singleton integration test, which cannot deterministically exercise multi-ticket interleavings.

  2. The production wiring is not asserted. The stated reason for loadGuard.begin().let { scope.launch { … } } (L240‑241) is "a Clear before the IO coroutine starts must also win." No test covers that ordering — the tests always call loadAsync with the default ticket after the load coroutine has already begun (RecentPagesDismissalDuringLoadTest.kt L57‑66 waits on readStarted before dismissing). The regression this PR is named for is therefore only covered for the mid-read window, not the pre-start window.

  3. Ticket release is not asserted. No test checks that active is empty after loadAsync completes/throws/is cancelled (i.e. the leak in finding 4 would not be caught).

  4. DownloadDataProviderImpl: no test for a malformed destinationPath (InvalidPathException, finding 1), and no test for a delete failure that the user cannot resolve, which is the case that produces a stuck row. The settled == null race (finding 10) is also untested; settled cancellation can remove tracking even after engine releases handle (new L269‑282) drives CANCELLED, not entry-removal-to-null.

  5. Global-state hazard in the new test (RecentPagesDismissalDuringLoadTest.kt): it mutates the production singleton's settingsFile (L46) and restores two private MutableStateFlows by reflection (L96‑105). Concerns:

    • If any assertion or manager.clearAll().join() inside the outer finally (L84‑90) throws, manager.settingsFile = original (L89) never runs and every later test/production write in the JVM targets a deleted temp dir. Restore settingsFile first in the finally.
    • Reflection on "_recentPages" / "_dismissedSuggestions" string literals breaks silently on rename (runtime NoSuchFieldException, not a compile error).
    • Four tests in this class serially mutate one singleton; any other test in the same JVM touching RecentBrowserPagesManager concurrently will make file.readText() assertions (L88‑89 of verifyOutcome) flaky.
    • withTimeout(2_000) { while (…) yield() } (L74‑76) busy-spins the runBlocking dispatcher waiting for recordPageVisit to land on another dispatcher — a real wall-clock dependency that will be the first thing to flake on loaded CI.

Suggested minimum before merge

  1. Guard Path.of (or keep File) so a malformed destinationPath cannot make a row permanently unremovable; decide and document the escape hatch for unresolvable delete failures (finding 1).
  2. Restore a log line with id/status on the rejected-cancel path (finding 2).
  3. Release the guard ticket via invokeOnCompletion so a cancelled startup coroutine cannot leak it (finding 4).
  4. Add direct RecentPagesLoadGuard unit tests and an assertion that active drains (gaps 11, 13).
  5. Reorder the test finally to restore settingsFile before anything that can throw (gap 15).

@kshivang
kshivang merged commit 1e3c023 into dev Sep 25, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.